Test dla place_order potrzebuje bramki płatności, klienta SMS i kalkulatora podatku. Podłącz prawdziwe zależności i każde uruchomienie testu obciąży kartę i wyśle SMS na prawdziwy numer. Test jednostkowy ma działać w milisekundach, bez sieci, więc wszystkie trzy trzeba czymś zastąpić. To “coś innego” ma pięć odmian, każda rozwiązuje inny problem, a traktowanie ich jako wymienne jest powodem, dla którego spory “po prostu zmockuj to” w code review nie mają końca.

Czym jest test double

Gerard Meszaros wprowadził termin “test double” w xUnit Test Patterns jako wspólną nazwę dla wszystkiego, co zastępuje prawdziwą zależność w teście, tak jak dubler zastępuje aktora. Na planie filmowym różne ujęcia wymagają różnych dublerów: jeden musi tylko stać w tle z odpowiednią fryzurą, inny faktycznie rozbija samochód. Test doubles dzielą się tak samo. Dummy, stub, spy, fake i mock to pięć różnych zadań, a to, co je odróżnia, to to, co test sprawdza, a nie to, jak wyszukany jest sam zastępnik.

Oto klasa, którą testuje każdy przykład poniżej. Trzy jej zależności warto zastąpić; czwarta okazuje się nie mieć żadnego znaczenia:

 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 działa tylko na ścieżce błędu. To ma znaczenie w pierwszym przykładzie.

Dwa małe zastępniki wracają w kilku kolejnych testach, więc definiujemy je raz tutaj:

 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

Czym jest dummy object w testach

Dummy jest przekazywany tylko, żeby zaspokoić parametr, którego test nigdy nie wywołuje. Nie robi niczego i niczego nie sprawdza. Test poniżej, dla ścieżki happy path, nigdy nie dociera do gałęzi z wyjątkiem, więc logger może być czymkolwiek, co przetrwa przypisanie do atrybutu:

 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() działa, bo nikt nie wywołuje na nim żadnej metody. Jeśli późniejsza zmiana sprawiłaby, że place_order loguje też na ścieżce sukcesu, ten test zawiedzie z AttributeError. To przydatna awaria: mówi, że dummy przestał być dummy.

Czym jest stub w testach jednostkowych

Stub zwraca gotową odpowiedź, dzięki czemu reszta testu zostaje deterministyczna. Prawdziwy kalkulator podatku mógłby wywoływać API ze stawkami podatkowymi. Test tego nie chce, więc StubTaxCalculator zwraca 108.00 niezależnie od tego, jaki subtotal dostanie. Widziałeś go już w akcji w teście z dummy powyżej, razem ze StubGateway, który robi to samo dla charge.

Nic nie sprawdza, czy total_with_tax zostało wywołane albo z jakim argumentem. Stub podaje testowanemu kodowi kontrolowane dane wejściowe; nigdy nie weryfikuje interakcji. To jest granica między stubem a kolejnymi trzema test doubles.

Czym jest test spy

Spy zapisuje wywołania, które do niego trafiają, żeby test mógł je sprawdzić po tym, jak testowany kod się wykonał. unittest.mock.Mock automatycznie zapisuje każde wywołanie, więc większość testów w Pythonie używa go jako spy, nawet jeśli to słowo nigdy się w nich nie pojawia:

 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"
    )

Przed wywołaniem na sms_spy nic nie było skonfigurowane. Przyjął to, co otrzymał, zapisał to, a test odpytał go o to już po fakcie.

Czym jest fake object w testach

Stub nie powie ci, czy dwie płatności pod rząd otrzymują dwa różne ID, bo zna tylko jedną wartość. Kiedy test potrzebuje zachowania obejmującego kilka wywołań, potrzebujesz fake: małej implementacji, która faktycznie działa.

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 nie wykonuje żadnego połączenia sieciowego i nie ma za sobą prawdziwego procesora płatności, ale trzyma stan tak, jak robi to prawdziwa bramka. Test potrzebował właśnie takiego stanu, a stub nie mógł go dać.

Czym jest mock object

W definicji Meszarosa mock wnosi do testu własne oczekiwania: reguły poprawnego wywołania są ustalone, zanim kod się wykona, a wywołanie, które je łamie, zawodzi natychmiast. unittest.mock jest w tym luźniejszy, bo nadal asertujesz po fakcie, ale create_autospec daje ci ważną część tego mechanizmu. Buduje zastępnik na podstawie prawdziwej klasy, więc każde wywołanie jest sprawdzane względem prawdziwych sygnatur metod w momencie, gdy się dzieje:

 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)

Zmień place_order, żeby wywoływał self.gateway.charge(total, currency="usd"), i ten test zawiedzie z TypeError wewnątrz place_order, bo Gateway.charge nie przyjmuje argumentu currency. Teraz wyobraź sobie w tym miejscu zwykły Mock(), z asercją zaktualizowaną przez kogoś tak, żeby pasowała do nowego wywołania. Test przechodzi, a kod łamie się przy pierwszym kontakcie z prawdziwą bramką. Spy potwierdza, że wywołanie się zdarzyło. Mock z autospec potwierdza też, że to wywołanie jest takie, jakie przyjęłaby prawdziwa klasa.

Mock vs stub vs spy vs fake: tabela porównawcza

DoubleCo robiKiedy po niego sięgnąć
DummyPrzekazywany, żeby zaspokoić parametr; nigdy nie wywoływanyZależność istnieje tylko, bo sygnatura jej wymaga na tej ścieżce
StubZwraca jedną gotową odpowiedź niezależnie od danych wejściowychTest potrzebuje stałej, deterministycznej wartości z zależności
SpyZapisuje wywołania, żeby test mógł je sprawdzić po fakcieTest musi potwierdzić, że interakcja się zdarzyła, bez wcześniejszej konfiguracji
FakeMniejsza, ale działająca implementacja prawdziwej rzeczyTest potrzebuje realnego zachowania przy wielu wywołaniach, nie jednej gotowej wartości
MockZbudowany z oczekiwaniami co do poprawnych wywołań, sprawdzanymi na bieżącoTest musi zweryfikować dokładny kształt wywołania: argumenty, liczbę, kolejność

gateway był stubem, fake i mockiem w trzech testach tej samej klasy. Rodzaj test double zależy od tego, co test sprawdza, nie od zależności, którą zastępuje.

Kiedy test doubles pogarszają testy

Mockowanie każdego współpracownika wiąże test z tym, jak kod jest napisany, nie z tym, co robi. Dodaj test sprawdzający, że total_with_tax jest wywoływane równo raz, potem zmień place_order, żeby wywoływał je drugi raz na potrzeby logowania: test się wywali, a dla użytkownika nic się nie zmieniło. Test z fake powyżej nie ma tego problemu, bo sprawdza wynik (dwa różne ID płatności, poprawne zapisane kwoty), nie kształt wywołania.

Mockowanie biblioteki, której nie jesteś właścicielem, ma ten sam problem, tylko jeden poziom wyżej. Autospecuj klienta zewnętrznej biblioteki bezpośrednio i każdy test, który go dotyka, zależy od sygnatur metod tej biblioteki; drobny upgrade, który zmienia nazwę parametru, wywala testy w całym pakiecie. Owiń bibliotekę w swój własny mały interfejs i zastępuj ten interfejs. Kiedy biblioteka się zmieni, zmieni się tylko wrapper.

Czasem właściwa liczba test doubles to zero. Jeśli prawdziwa zależność jest już szybka i nie ma efektów zewnętrznych, jak czysta funkcja albo połączenie SQLite w pamięci, dublowanie jej dodaje tylko poziom pośredni i niczego nie wykrywa. Test doubles są dla zależności, które są wolne, zdalne, niedeterministyczne albo mają efekty zewnętrzne, których nie chcesz w uruchomieniu testu.

Jak wybrać odpowiedni test double

Zacznij od tego, co test sprawdza, nie od tego, co wydaje się najbezpieczniej zastąpić. Jeśli potrzebujesz przewidywalnego wejścia, żeby później zadziałała asercja, zrób stub. Żeby potwierdzić, że SMS poszedł, użyj spy. Jeśli zależność musi zachowywać się poprawnie przy kilku wywołaniach, napisz fake raz i używaj go wszędzie, gdzie ta zależność się pojawia. A kiedy caller podający błędne argumenty powinien wywalić test, zanim wywali produkcję, użyj mocka z autospec.

Żadne z tego nie zadziała na klasie, która sama buduje swoje zależności wewnątrz, bo nie ma gdzie podać zastępnika. To problem, który rozwiązuje dependency injection, i to pierwsza rzecz do naprawienia, jeśli klasa opiera się każdemu test double z tego artykułu. Kiedy już masz ten punkt wejścia, zdecyduj, który double trafia na który poziom testów. Jeśli pracujesz test-first, po test doubles sięgasz wcześnie, bo test istnieje, zanim istnieje prawdziwy kod do wywołania.

Powiązane artykuły