Un test per place_order ha bisogno di un gateway di pagamento, un client SMS e un calcolatore delle tasse. Se collegassi quelli veri, ogni esecuzione del test addebiterebbe una carta e manderebbe un SMS a un numero reale. Un test unitario deve girare in pochi millisecondi, senza rete, quindi tutti e tre vanno sostituiti con qualcos’altro. Quel “qualcos’altro” si presenta in cinque forme — ognuna risolve un problema diverso — e trattarle come intercambiabili è il motivo per cui le discussioni “basta mockarlo” in code review girano in tondo.

Cos’è un test double

Gerard Meszaros ha coniato il termine “test double” in xUnit Test Patterns come termine ombrello per qualunque cosa sostituisca una dipendenza reale durante un test, allo stesso modo in cui una controfigura sostituisce un attore sul set. Le troupe cinematografiche usano controfigure diverse per inquadrature diverse: una deve solo stare sullo sfondo con il taglio di capelli giusto, un’altra fa davvero lo schianto con l’auto. I test double si dividono nello stesso modo. Dummy, stub, spy, fake e mock fanno cinque lavori diversi, e ciò che li separa è cosa verifica il test, non quanto sia elaborato l’oggetto sostitutivo.

Ecco la classe che ogni esempio qui sotto testa. Tre delle sue dipendenze vale la pena sostituirle; la quarta, come vedrai, non conta affatto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class PaymentDeclined(Exception):
    pass


class OrderProcessor:
    def __init__(self, gateway, sms_client, tax_calculator, logger):
        self.gateway = gateway
        self.sms_client = sms_client
        self.tax_calculator = tax_calculator
        self.logger = logger

    def place_order(self, customer_phone, subtotal):
        total = self.tax_calculator.total_with_tax(subtotal)
        try:
            charge_id = self.gateway.charge(total)
        except PaymentDeclined:
            self.logger.warning("payment declined for %s", customer_phone)
            raise
        self.sms_client.send(customer_phone, f"Order confirmed, charged ${total:.2f}")
        return charge_id

logger entra in gioco solo sul percorso d’errore. Questo conta per il primo esempio.

Due piccoli sostituti vengono riusati nei prossimi test, quindi li definiamo una volta sola qui:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from unittest.mock import Mock, create_autospec


class StubGateway:
    def __init__(self, charge_id):
        self.charge_id = charge_id

    def charge(self, amount):
        return self.charge_id


class StubTaxCalculator:
    def __init__(self, total):
        self.total = total

    def total_with_tax(self, subtotal):
        return self.total

Cos’è un oggetto dummy nei test

Un dummy viene passato solo per soddisfare un parametro che il test non esercita mai. Non fa nulla e non verifica nulla. Il test del percorso felice qui sotto non arriva mai al ramo dell’eccezione, quindi logger può essere qualunque cosa sopravviva all’assegnazione a un attributo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def test_place_order_returns_the_charge_id():
    dummy_logger = object()  # never called on this path
    processor = OrderProcessor(
        gateway=StubGateway(charge_id="ch_1"),
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=dummy_logger,
    )

    charge_id = processor.place_order("+15551234567", subtotal=100.00)

    assert charge_id == "ch_1"

object() funziona perché nessuno chiama un metodo su di esso. Se una modifica successiva facesse loggare place_order anche sul percorso di successo, questo test fallirebbe con un AttributeError. Quel fallimento è utile: ti dice che il dummy ha smesso di essere un dummy.

Cos’è uno stub nei test unitari

Uno stub restituisce una risposta prestabilita per mantenere il resto del test deterministico. Il calcolatore delle tasse vero potrebbe chiamare un’API delle aliquote. Il test non vuole quello, quindi StubTaxCalculator restituisce sempre 108.00, qualunque subtotale riceva. Lo hai già visto all’opera nel test del dummy sopra, insieme a StubGateway che fa lo stesso lavoro per charge.

Niente verifica se total_with_tax è stato chiamato, o con quale argomento. Uno stub fornisce al codice sotto test un input controllato; non verifica mai un’interazione. Questa è la linea che separa uno stub dai tre test double successivi.

Cos’è un test spy

Uno spy registra le chiamate che riceve così il test può ispezionarle dopo che il codice sotto test è stato eseguito. unittest.mock.Mock registra automaticamente ogni chiamata, quindi la maggior parte delle suite Python lo usa come spy anche quando la parola “spy” non compare mai nel test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_place_order_sends_a_confirmation_sms():
    sms_spy = Mock()
    processor = OrderProcessor(
        gateway=StubGateway(charge_id="ch_1"),
        sms_client=sms_spy,
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    processor.place_order("+15551234567", subtotal=100.00)

    sms_spy.send.assert_called_once_with(
        "+15551234567", "Order confirmed, charged $108.00"
    )

Nessuna configurazione è stata impostata su sms_spy prima della chiamata. Ha accettato qualunque cosa gli sia stata data, l’ha registrata, e il test l’ha interrogata dopo.

Cos’è un oggetto fake nei test

Uno stub non può dirti se due addebiti consecutivi ottengono due ID diversi, perché conosce solo un valore. Quando un test ha bisogno di un comportamento attraverso più chiamate, serve un fake: una piccola implementazione che funziona davvero.

1
2
3
4
5
6
7
8
class FakeGateway:
    def __init__(self):
        self.charges = []

    def charge(self, amount):
        charge_id = f"ch_{len(self.charges) + 1}"
        self.charges.append((charge_id, amount))
        return charge_id
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_two_orders_get_two_different_charge_ids():
    gateway = FakeGateway()
    processor = OrderProcessor(
        gateway=gateway,
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    first_id = processor.place_order("+15551234567", subtotal=100.00)
    second_id = processor.place_order("+15557654321", subtotal=100.00)

    assert first_id != second_id
    assert gateway.charges == [(first_id, 108.00), (second_id, 108.00)]

FakeGateway non ha chiamate di rete né un vero processore di pagamenti dietro, ma mantiene uno stato come farebbe quello vero. Il test aveva bisogno esattamente di quello stato, e uno stub non poteva fornirlo.

Cos’è un oggetto mock

Nella definizione di Meszaros, un mock porta le sue aspettative dentro il test: le regole per una chiamata valida sono fissate prima che il codice venga eseguito, e una chiamata che le viola fallisce sul colpo. unittest.mock è più permissivo di così, perché verifichi comunque dopo, ma create_autospec ti porta a metà strada verso quel comportamento. Costruisce il double a partire da una classe reale, quindi ogni chiamata viene controllata contro le firme reali dei metodi nel momento in cui avviene:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Gateway:
    def charge(self, amount: float) -> str:
        raise NotImplementedError


def test_place_order_charges_the_amount_after_tax():
    gateway = create_autospec(Gateway, instance=True)
    gateway.charge.return_value = "ch_1"
    processor = OrderProcessor(
        gateway=gateway,
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    processor.place_order("+15551234567", subtotal=100.00)

    gateway.charge.assert_called_once_with(108.00)

Modifica place_order per chiamare self.gateway.charge(total, currency="usd") e questo test fallisce con un TypeError dentro place_order, perché Gateway.charge non accetta un argomento currency. Immagina ora un semplice Mock() nello stesso punto, con qualcuno che aggiorna l’asserzione per farla corrispondere alla nuova chiamata: il test passa, e il codice si rompe la prima volta che incontra il gateway reale. Uno spy conferma che una chiamata è avvenuta. Un mock autospecced conferma anche che la chiamata è una di quelle che l’oggetto reale accetterebbe.

Mock vs stub vs spy vs fake: tabella di confronto

Test doubleCosa faUsalo quando
DummyPassato per soddisfare un parametro; non viene mai chiamatoUna dipendenza esiste solo perché la firma la richiede su questo percorso
StubRestituisce sempre la stessa risposta, qualunque sia l’inputIl test ha bisogno di un valore fisso e deterministico da una dipendenza
SpyRegistra le chiamate così il test può ispezionarle dopoIl test deve confermare che un’interazione è avvenuta, senza configurarla prima
FakeUna implementazione più piccola ma funzionante della cosa realeIl test ha bisogno di un comportamento reale attraverso più chiamate, non un singolo valore fisso
MockCostruito con aspettative sulle chiamate valide, verificate mentre avvengonoIl test deve verificare la forma esatta di una chiamata: argomenti, numero, ordine

gateway è stato uno stub, un fake e un mock in tre test della stessa classe. Il test double segue cosa verifica il test, non la dipendenza.

Quando i test double peggiorano i test

Mockare ogni collaboratore accoppia il test a come è scritto il codice, invece che a cosa fa. Aggiungi un test che verifica che total_with_tax venga chiamato esattamente una volta, poi modifica place_order per chiamarlo una seconda volta per il logging: il test si rompe, e niente che interessi a un utente è cambiato. Il test con il fake sopra non ha questo problema, perché verifica il risultato (due ID di addebito distinti, gli importi giusti registrati) invece della forma della chiamata.

Mockare una libreria che non controlli ha lo stesso problema a un livello più alto. Fai l’autospec diretto di un client di terze parti e ogni test che lo tocca dipende dalle firme dei metodi di quella libreria; un aggiornamento minore che rinomina un parametro rompe i test in tutta la suite. Avvolgi la libreria in una piccola interfaccia tua e sostituisci quella. Quando la libreria cambia, cambia solo il wrapper.

A volte il numero giusto di test double è zero. Se la dipendenza reale è già veloce e senza effetti collaterali, come una funzione pura o una connessione SQLite in memoria, sostituirla aggiunge indirezione e non cattura nulla. I test double servono per le dipendenze lente, remote, non deterministiche, o con effetti collaterali che non vuoi in un’esecuzione di test.

Come scegliere il test double giusto

Parti da cosa verifica il test, non da cosa sembra più sicuro simulare. Se serve un input prevedibile perché un’asserzione successiva tenga, usa uno stub. Per confermare che l’SMS sia partito, usa uno spy. Se la dipendenza deve comportarsi correttamente attraverso più chiamate, scrivi un fake una volta e riusalo ovunque quella dipendenza compaia. E quando un chiamante che passa gli argomenti sbagliati deve far fallire il test prima che fallisca in produzione, usa un mock autospecced.

Niente di tutto questo funziona su una classe che costruisce le proprie dipendenze internamente, perché non c’è un punto dove passare un double. È il problema che risolve la dependency injection, ed è la prima cosa da sistemare se una classe resiste a ogni double di questo articolo. Una volta che esiste il punto di innesto, decidi quale double appartiene a quale livello di test. Se lavori test-first, userai i test double da subito, perché il test esiste prima che ci sia codice reale da chiamare.

Articoli correlati