Ein Test für place_order braucht ein Payment-Gateway, einen SMS-Client und einen Steuerrechner. Setzen Sie die echten Objekte ein: Jeder Testlauf belastet dann eine Karte und verschickt eine SMS an eine echte Telefonnummer. Ein Unit-Test soll in Millisekunden laufen, ohne Netzwerkzugriff — also müssen alle drei durch etwas anderes ersetzt werden. Dieses „etwas andere“ gibt es in fünf Varianten, die jeweils ein anderes Problem lösen. Wer sie für austauschbar hält, erlebt im Code-Review immer wieder dieselbe Debatte: „dann mock das doch einfach“.

Was ist ein Test Double

Gerard Meszaros hat den Begriff „Test Double“ in xUnit Test Patterns geprägt — als Oberbegriff für alles, was bei einem Test für eine echte Abhängigkeit einspringt, so wie ein Stunt-Double für einen Schauspieler einspringt. Filmteams setzen für unterschiedliche Einstellungen unterschiedliche Doubles ein: Eines muss nur mit der richtigen Frisur im Hintergrund stehen, ein anderes übernimmt tatsächlich den Autounfall. Test Doubles teilen sich genauso auf. Dummy, Stub, Spy, Fake und Mock sind fünf unterschiedliche Aufgaben, und was sie trennt, ist das, was der Test prüft — nicht wie aufwendig das Ersatzobjekt gebaut ist.

Hier ist die Klasse, die jedes folgende Beispiel testet. Drei ihrer Abhängigkeiten lohnt es sich zu ersetzen; die vierte spielt am Ende gar keine Rolle:

 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 läuft nur auf dem Fehlerpfad. Das ist für das erste Beispiel wichtig.

Zwei kleine Ersatzobjekte werden in den nächsten Tests mehrfach verwendet und deshalb hier einmal definiert:

 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

Was ist ein Dummy-Objekt im Testing

Ein Dummy wird nur übergeben, um einen Parameter zu erfüllen, den der Test nie anfasst. Er tut nichts und prüft nichts. Der Happy-Path-Test unten erreicht den Exception-Zweig nie, also kann logger alles sein, was eine Zuweisung an ein Attribut überlebt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def test_place_order_returns_the_charge_id():
    dummy_logger = object()  # wird auf diesem Pfad nie aufgerufen
    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() funktioniert, weil niemand eine Methode darauf aufruft. Würde eine spätere Änderung dazu führen, dass place_order auch auf dem Erfolgspfad loggt, schlägt dieser Test mit einem AttributeError fehl. Dieser Fehlschlag ist nützlich: Er zeigt, dass der Dummy aufgehört hat, ein Dummy zu sein.

Was ist ein Stub im Unit-Test

Ein Stub liefert eine feste Antwort, damit der Rest des Tests deterministisch bleibt. Der echte Steuerrechner ruft vielleicht eine Steuersatz-API auf. Das will der Test nicht, also liefert StubTaxCalculator immer 108.00, unabhängig vom übergebenen Betrag. Sie haben ihn bereits im Dummy-Test oben gesehen, zusammen mit StubGateway, das denselben Job für charge übernimmt.

Niemand prüft, ob total_with_tax aufgerufen wurde oder mit welchem Argument. Ein Stub liefert dem getesteten Code eine kontrollierte Eingabe; er verifiziert nie eine Interaktion. Das ist die Grenze zwischen einem Stub und den nächsten drei Doubles.

Was ist ein Test-Spy

Ein Spy zeichnet die Aufrufe auf, die er erhält, damit der Test sie nach dem Lauf des getesteten Codes untersuchen kann. unittest.mock.Mock zeichnet automatisch jeden Aufruf auf, deshalb nutzen die meisten Python-Suiten ihn als Spy, selbst wenn das Wort im Test nie auftaucht:

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

Auf sms_spy wurde vor dem Aufruf nichts konfiguriert. Er akzeptierte, was immer er bekam, zeichnete es auf, und der Test befragte ihn danach.

Was ist ein Fake-Objekt im Testing

Ein Stub kann nicht sagen, ob zwei Belastungen hintereinander zwei unterschiedliche IDs erhalten, weil er nur einen einzigen Wert kennt. Braucht ein Test Verhalten über mehrere Aufrufe hinweg, brauchen Sie einen Fake: eine kleine Implementierung, die tatsächlich funktioniert.

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 hat keinen Netzwerkaufruf und keinen echten Zahlungsdienstleister dahinter, hält aber einen Zustand wie das echte Gateway. Genau diesen Zustand brauchte der Test, und ein Stub hätte ihn nicht liefern können.

Was ist ein Mock-Objekt

Nach Meszaros’ Definition trägt ein Mock seine Erwartungen in den Test hinein: Die Regeln für einen gültigen Aufruf stehen fest, bevor der Code läuft, und ein Aufruf, der sie verletzt, scheitert sofort. unittest.mock ist lockerer als das, weil Sie trotzdem erst danach prüfen — aber create_autospec bringt die wichtigere Hälfte davon. Es baut das Double aus einer echten Klasse, sodass jeder Aufruf sofort gegen die echten Methodensignaturen geprüft wird:

 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)

Ändern Sie place_order so, dass es self.gateway.charge(total, currency="usd") aufruft, schlägt dieser Test mit einem TypeError innerhalb von place_order fehl, weil Gateway.charge kein currency-Argument akzeptiert. Stellen Sie sich an derselben Stelle einen einfachen Mock() vor, bei dem jemand die Assertion an den neuen Aufruf anpasst: Der Test läuft durch, und der Code bricht beim ersten echten Kontakt mit dem echten Gateway. Ein Spy bestätigt, dass ein Aufruf stattgefunden hat. Ein autospecced Mock bestätigt zusätzlich, dass es ein Aufruf ist, den das echte Objekt akzeptieren würde.

Mock vs Stub vs Spy vs Fake: Vergleichstabelle

DoubleWas es machtVerwenden Sie es, wenn …
DummyWird übergeben, um einen Parameter zu erfüllen; wird nie aufgerufenEine Abhängigkeit existiert auf diesem Pfad nur, weil die Signatur sie verlangt
StubLiefert bei jeder Eingabe dieselbe feste AntwortDer Test braucht einen festen, deterministischen Wert aus einer Abhängigkeit
SpyZeichnet Aufrufe auf, damit der Test sie danach untersuchen kannDer Test muss bestätigen, dass eine Interaktion stattgefunden hat, ohne sie vorher zu konfigurieren
FakeEine kleinere, aber funktionierende Implementierung des echten DingsDer Test braucht echtes Verhalten über mehrere Aufrufe hinweg, nicht einen einzelnen Wert
MockMit Erwartungen an gültige Aufrufe gebaut, die beim Aufruf selbst geprüft werdenDer Test muss die genaue Form eines Aufrufs prüfen: Argumente, Anzahl, Reihenfolge

gateway war in drei Tests derselben Klasse ein Stub, ein Fake und ein Mock. Das Double richtet sich danach, was der Test prüft, nicht nach der Abhängigkeit.

Wann Test Doubles Tests schlechter machen

Jede Abhängigkeit zu mocken koppelt den Test daran, wie der Code geschrieben ist, statt daran, was er tut. Fügen Sie einen Test hinzu, der prüft, dass total_with_tax genau einmal aufgerufen wird, und refaktorieren Sie place_order so, dass es ein zweites Mal für Logging aufgerufen wird: Der Test schlägt fehl, und für die Nutzer hat sich nichts geändert. Der Fake-Test oben hat dieses Problem nicht, weil er das Ergebnis prüft (zwei unterschiedliche Charge-IDs, die richtigen erfassten Beträge) statt die Form des Aufrufs.

Eine Bibliothek zu mocken, die Sie nicht selbst besitzen, hat dasselbe Problem eine Ebene höher. Setzen Sie create_autospec direkt auf einen Drittanbieter-Client an, hängt jeder Test, der ihn berührt, von den Methodensignaturen dieser Bibliothek ab; ein kleines Update, das einen Parameter umbenennt, bricht Tests in der ganzen Suite. Verpacken Sie die Bibliothek in eine eigene kleine Schnittstelle und doppeln Sie diese stattdessen. Ändert sich die Bibliothek, ändert sich nur der Wrapper.

Manchmal ist die richtige Anzahl an Doubles null. Ist die echte Abhängigkeit bereits schnell und ohne Nebeneffekte, wie eine pure Funktion oder eine In-Memory-SQLite-Verbindung, fügt ein Double nur Indirektion hinzu und fängt nichts ab. Doubles sind für Abhängigkeiten, die langsam, entfernt, nicht-deterministisch sind oder Nebeneffekte haben, die in einem Testlauf nichts zu suchen haben.

Wie Sie das richtige Test Double wählen

Gehen Sie von dem aus, was der Test prüft, nicht von dem, was sich am sichersten anfühlt zu fälschen. Brauchen Sie eine vorhersagbare Eingabe, damit eine spätere Assertion hält, stubben Sie sie. Um zu bestätigen, dass die SMS verschickt wurde, setzen Sie einen Spy ein. Muss sich die Abhängigkeit über mehrere Aufrufe hinweg korrekt verhalten, schreiben Sie einmal einen Fake und verwenden Sie ihn überall, wo diese Abhängigkeit vorkommt. Und wenn ein Aufrufer mit falschen Argumenten den Test scheitern lassen soll, bevor er in Produktion scheitert, verwenden Sie einen autospecced Mock.

Nichts davon funktioniert bei einer Klasse, die ihre Abhängigkeiten selbst erzeugt, denn dann gibt es keine Stelle, an der Sie ein Double übergeben könnten. Dieses Problem löst Dependency Injection, und es ist das Erste, was Sie beheben sollten, wenn sich eine Klasse gegen jedes Double in diesem Artikel sperrt. Sobald die Nahtstelle existiert, entscheiden Sie, welches Double auf welcher Teststufe hingehört. Arbeiten Sie testgetrieben, greifen Sie früh zu Doubles, weil der Test existiert, bevor es echten Code zum Aufrufen gibt.

Verwandte Artikel