Mocking — prüfen, wie ein externer Dienst aufgerufen wurde
Setze einen Mock als Stellvertreter für einen Payment- oder E-Mail-Client ein und prüfe, wie er aufgerufen wurde: return_value, side_effect, autospec, patch, mocker — und wann ein Fake besser als ein Mock ist.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist ein kleiner Checkout. Er berechnet die Gesamtsumme eines Warenkorbs, belastet eine Karte über einen Payment-Client und schickt eine E-Mail an den Kunden.
payments.py:
class PaymentDeclined(Exception):
pass
class PaymentGateway:
"""Talks to the real payment provider over HTTPS."""
def __init__(self, api_key):
self.api_key = api_key
def charge(self, amount_cents, token):
raise RuntimeError("real network call — never in a test")
def refund(self, charge_id):
raise RuntimeError("real network call — never in a test")checkout.py:
from payments import PaymentDeclined
def cart_total(cart):
return sum(item["price_cents"] * item["qty"] for item in cart)
def place_order(cart, email, token, gateway, mailer):
total = cart_total(cart)
if total == 0:
raise ValueError("cart is empty")
try:
receipt = gateway.charge(total, token=token)
except PaymentDeclined:
mailer.send(email, "Your payment was declined")
return {"status": "declined"}
mailer.send(email, f"Order confirmed: {receipt['id']}")
return {"status": "paid", "charge_id": receipt["id"], "total_cents": total}Im echten Leben bucht charge Geld von einer echten Karte ab, ein Test darf diesen Aufruf also niemals erreichen. Im letzten Kapitel konntest du mit monkeypatch Dinge austauschen — aber das Austauschen ist hier nur die halbe Miete. Der Rückgabewert von place_order meldet „paid“, während die entscheidenden Fehler in den Aufrufen stecken: Wurde die Karte mit 2500 oder mit 25 belastet? Mit welchem Token? Einmal oder zweimal? Ging die E-Mail an die richtige Adresse? Um diese Fragen zu beantworten, muss sich der Stellvertreter merken, wie er aufgerufen wurde.
Du könntest dafür eine kleine Protokollklasse schreiben, eine für jede Abhängigkeit. unittest.mock hat das bereits für dich erledigt.
Am Ende dieses Kapitels kannst du
Mockals Stellvertreter für einen Client einsetzen und mitassert_called_once_with,call_args,call_countundmock_callsprüfen, wie er aufgerufen wurde- Einen Mock über
return_valueundside_effectWerte zurückgeben oder Exceptions (Ausnahmen) auslösen lassen - Erklären, warum ein einfacher
Mockgefährlich ist, und die Lücke mitspec=undcreate_autospecschließen - Einen Namen mit
patchals Dekorator und als Context-Manager an der richtigen Stelle ersetzen mocker.patchundmocker.spyaus pytest-mock verwenden- Stub, Mock und Fake voneinander unterscheiden und wissen, wann ein Fake der bessere Test ist
Voraussetzungen: monkeypatch — Dinge für einen einzelnen Test austauschen.
Bevor du den Test schreibst
Bevor ein einziger Mock ins Spiel kommt, schreibe auf, was place_order eigentlich verspricht. Die Funktion hat eine Eingabe, die du kontrollierst (Warenkorb, E-Mail-Adresse und Token), und zwei Nebeneffekte, die du am Rückgabewert nicht ablesen kannst — eine Abbuchung und eine E-Mail. Ein Test für solchen Code muss beide Hälften prüfen.
Der Vertrag. Bei einem nicht-leeren Warenkorb bucht place_order genau den Gesamtbetrag einmal mit dem angegebenen Token ab, verschickt eine Bestätigung per E-Mail und gibt „paid“ zurück. Wird die Karte abgelehnt, bucht die Funktion nichts ab, verschickt eine Entschuldigungs-E-Mail und gibt „declined“ zurück. Ein leerer Warenkorb löst einen ValueError aus, bevor überhaupt etwas passiert. Ein Netzwerkfehler wird nicht verschluckt.
Das Setup. pytest in deiner virtuellen Umgebung, payments.py und checkout.py importierbar von dem Ort, an dem du pytest ausführst, und das Plugin pytest-mock für die mocker-Fixture (pip install pytest-mock). unittest.mock ist bereits in Python enthalten. Kein API-Schlüssel, kein Netzwerk — wenn ein Test eines von beiden benötigt, ist es kein Unit-Test.
Der Plan.
| Fall | Eingabe | Erwartet | |---|---|---| | Erfolgsfall | 2 × 1000 + 1 × 500, Karte akzeptiert | eine Abbuchung von 2500 mit tok_visa; Bestätigungs-E-Mail; „paid“ | | Abgelehnte Karte | gleicher Warenkorb, Gateway löst PaymentDeclined aus | keine Abbuchung erfasst; Entschuldigungs-E-Mail; „declined“ | | Leerer Warenkorb | [] | ValueError("cart is empty"); Gateway und Mailer unberührt | | Netzwerkfehler | Gateway löst TimeoutError aus | TimeoutError wird weitergereicht; keine E-Mail | | Vertrag | beliebige Bestellung | charge wird mit der echten Signatur (amount_cents, token) aufgerufen |
Was du nicht testest. Den Payment-Provider selbst — das ist die Aufgabe seiner Entwickler, und dein Test kann ihn ohnehin nicht erreichen. Pythons sum. Und die internen Schritte von place_order: Ob die Funktion cart_total einmal aufruft oder die Berechnung direkt einbettet, ist ihre eigene Sache. Der Plan führt Ergebnisse auf und den einen Aufruf, der die Systemgrenze überschreitet — sonst nichts. Behalte das im Hinterkopf: Das ist der Unterschied zwischen einem nützlichen und einem schädlichen Mock, und am Ende des Kapitels kommen wir darauf zurück.
Mock — ein Objekt, das jeden Aufruf aufzeichnet
from unittest.mock import Mock
gateway = Mock()
result = gateway.charge(2500, token="tok_visa")
gateway.refund("ch_1")
print(result)
print(gateway.charge.called, gateway.charge.call_count)
print(gateway.charge.call_args)
print(gateway.mock_calls)<Mock name='mock.charge()' id='132252166523936'>
True 1
call(2500, token='tok_visa')
[call.charge(2500, token='tok_visa'), call.refund('ch_1')]gateway besitzt keine charge-Methode; nichts davon wurde definiert. Ein Mock erfindet jedes Attribut, auf das du zugreifst, und jedes Attribut ist selbst wieder ein Mock, der aufgerufen werden kann. Jeder Aufruf wird aufgezeichnet: called verrät, ob er stattgefunden hat, call_count, wie oft, und call_args, mit genau welchen Argumenten. mock_calls auf dem Elternobjekt enthält jeden Aufruf jeder Methode in der richtigen Reihenfolge — praktisch, wenn die Reihenfolge eine Rolle spielt.
Diese Attribute in einem einfachen assert abzufragen funktioniert zwar, aber die Assertion-Methoden erledigen das in einer einzigen Zeile und erklären sich selbst, wenn sie fehlschlagen:
from unittest.mock import Mock
gateway = Mock()
gateway.charge(2500, token="tok_visa")
gateway.charge.assert_called_once_with(2500, token="tok_visa")
print("first check passed")
gateway.charge.assert_called_once_with(2500, token="tok_amex")first check passed
Traceback (most recent call last):
...
AssertionError: expected call not found.
Expected: charge(2500, token='tok_amex')
Actual: charge(2500, token='tok_visa')assert_called_once_with prüft zwei Dinge: Es gab genau einen Aufruf, und er hatte genau diese Argumente. Verwandte Methoden sind assert_called_once() (einmal, beliebige Argumente), assert_called_with(...) (nur der letzte Aufruf) und assert_not_called(). (Tracebacks in diesem Kapitel sind mit ... gekürzt, wo nur die letzte Zeile wichtig ist.)
Warum „einmal“? Weil eine Karte zweimal zu belasten ein echter Bug ist und assert_called_with allein einen zweiten Aufruf gar nicht bemerken würde.
return_value und side_effect — was der Mock zurückgibt
Standardmäßig liefert ein Aufruf einen weiteren Mock zurück. Code, der auf receipt["id"] zugreift, braucht jedoch eine echte Antwort — also gib ihm eine:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.return_value = {"id": "ch_1", "status": "succeeded"}
print(gateway.charge(2500, token="tok_visa"))
print(gateway.charge(999, token="tok_other")){'id': 'ch_1', 'status': 'succeeded'}
{'id': 'ch_1', 'status': 'succeeded'}Dieselbe Antwort, ganz gleich, welche Argumente übergeben werden. Wenn das nicht ausreicht, setze side_effect. Dafür gibt es drei Varianten.
Eine Exception — gateway.charge.side_effect = PaymentDeclined("card declined") sorgt dafür, dass jeder Aufruf diese Exception auslöst. So erreichst du die Fehlerpfade, die sich im echten Leben nur schwer provozieren lassen: eine abgelehnte Karte, ein Timeout, ein 500er-Fehler vom Server.
Eine Liste — ein Element pro Aufruf, der Reihe nach. Eine Exception in der Liste wird ausgelöst; alles andere wird zurückgegeben. Ideal für „schlägt einmal fehl, klappt danach“:
from unittest.mock import Mock
gateway = Mock()
gateway.charge.side_effect = [
TimeoutError("gateway timed out"),
{"id": "ch_2", "status": "succeeded"},
]
try:
gateway.charge(2500, token="tok_visa")
except TimeoutError as exc:
print("first call:", exc)
print("second call:", gateway.charge(2500, token="tok_visa"))
gateway.charge(2500, token="tok_visa")first call: gateway timed out
second call: {'id': 'ch_2', 'status': 'succeeded'}
Traceback (most recent call last):
...
StopIterationEin dritter Aufruf fand eine leere Liste vor. StopIteration bei einem Mock bedeutet, dass der Code ihn öfter aufgerufen hat, als du eingeplant hattest — was für sich genommen schon gut zu wissen ist.
Eine Funktion — sie wird mit denselben Argumenten aufgerufen wie der Mock, und was immer sie zurückgibt, ist das Ergebnis. gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"} liefert eine Antwort, die von der Eingabe abhängt — und die Aufrufe werden trotzdem aufgezeichnet und gezählt.
MagicMock — wenn der Code len, with oder for verwendet
Ein einfacher Mock unterstützt Pythons magische Methoden nicht: len(Mock()) löst TypeError: object of type 'Mock' has no len() aus, und auch with Mock(): schlägt fehl. MagicMock unterstützt sie mit sinnvollen Standardwerten — len(MagicMock()) ergibt 0, es funktioniert in einem with-Block und m.__len__.return_value = 3 ändert das Ergebnis.
Greife zu MagicMock, wenn der Stellvertreter als Context-Manager, Container oder Iterator eingesetzt wird — zum Beispiel als Datenbankverbindung in einem with-Block. patch und mocker.patch, die wir weiter unten sehen, erstellen standardmäßig MagicMock-Objekte.
Die Gefahr: Ein Mock sagt zu allem Ja
Diese entgegenkommende Art hat ihren Preis. Schreibst du einen Methodennamen falsch, beschwert sich niemand:
from unittest.mock import Mock
gateway = Mock()
gateway.chrage(2500, token="tok_visa") # typo in the method name
print("no error — the typo was recorded as a new method")
print(gateway.mock_calls)no error — the typo was recorded as a new method
[call.chrage(2500, token='tok_visa')]Wäre dieser Tippfehler in checkout.py, könnte ein Test mit einem einfachen Mock problemlos bestehen, und der erste echte Kunde würde einen AttributeError erhalten. Falsche Argumente rutschen auf dieselbe Weise durch. Hier siehst du die falsche und die richtige Herangehensweise nebeneinander — der zu testende Code vergisst das Token:
from unittest.mock import Mock, create_autospec
from payments import PaymentGateway
def charge_order(gateway, total, token):
return gateway.charge(total) # bug: the token is never passed
def test_with_a_plain_mock():
gateway = Mock()
charge_order(gateway, 2500, "tok_visa")
gateway.charge.assert_called_once()
def test_with_an_autospec():
gateway = create_autospec(PaymentGateway, instance=True)
charge_order(gateway, 2500, "tok_visa")
gateway.charge.assert_called_once()$ pytest -q --tb=line test_typo.py
.F [100%]
=================================== FAILURES ===================================
E TypeError: missing a required argument: 'token'
/usr/lib/python3.12/inspect.py:3157: TypeError: missing a required argument: 'token'
=========================== short test summary info ============================
FAILED test_typo.py::test_with_an_autospec - TypeError: missing a required ar...
1 failed, 1 passed in 0.07sDer einfache Mock hat einen fehlerhaften Aufruf durchgewunken — ein grüner Test, der vor gar nichts schützt. create_autospec baut einen Mock auf Basis des echten PaymentGateway auf: Er besitzt nur die Attribute dieser Klasse, und jede Methode prüft ihre Argumente gegen die echte Signatur. instance=True bedeutet „eine Instanz der Klasse“, nicht die Klasse selbst.
Drei Stufen der Strenge:
Mock()— beliebige Attribute, beliebige Argumente.Mock(spec=PaymentGateway)— nur echte Attributnamen (gateway.chragelöstAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?aus), aber Argumente werden nicht geprüft.create_autospec(PaymentGateway, instance=True)oderautospec=Truebeipatch— echte Namen und echte Signaturen.
Warum ist das über Tippfehler hinaus wichtig? Weil sich der echte Client weiterentwickelt. Wenn bei einem Bibliotheks-Upgrade eine Methode umbenannt oder ein Pflichtargument hinzugefügt wird, schlägt ein Autospec-Mock noch am selben Tag fehl; ein einfacher Mock stimmt weiterhin fröhlich Code zu, der gar nicht mehr funktioniert. Für alles, was als Stellvertreter für eine echte Klasse dient: Nutze die strengste Stufe.
Vor einer bestimmten Art von Tippfehler schützt Python sich inzwischen selbst: Seit Version 3.12 löst eine falsch geschriebene Assertion wie assert_called_once_wiht einen AttributeError: 'assert_called_once_wiht' is not a valid assertion. aus, statt stillschweigend durchzugehen. Dieser Schutz kennt jedoch nur die Assertion-Namen des Mocks selbst. Er kann nicht wissen, dass dein Gateway charge heißt und nicht chrage — das kann nur eine Spec.
patch — einen Namen ersetzen, den der Code selbst nachschlägt
place_order erhält sein Gateway als Argument, daher ist die Übergabe eines Mocks kinderleicht. Oft holt sich der Code seine Abhängigkeit jedoch selbst:
# emailer.py
def send_email(to, subject):
raise RuntimeError("real SMTP call — never in a test")
# signup.py
from emailer import send_email
def register(email):
send_email(email, "Welcome aboard")
return {"email": email, "active": True}unittest.mock.patch ersetzt einen Namen für die Dauer eines Tests durch einen MagicMock und setzt danach das Original wieder ein. Das funktioniert als Dekorator — der Mock wird als Argument übergeben — oder als Context-Manager:
from unittest.mock import patch
from signup import register
@patch("signup.send_email")
def test_register_as_decorator(send_email):
user = register("[email protected]")
assert user["active"] is True
send_email.assert_called_once_with("[email protected]", "Welcome aboard")
def test_register_as_context_manager():
with patch("signup.send_email") as send_email:
register("[email protected]")
send_email.assert_called_once_with("[email protected]", "Welcome aboard")$ pytest -q test_signup.py
.. [100%]
2 passed in 0.08sDas Ziel ist "signup.send_email", nicht "emailer.send_email". Das ist die Regel aus dem letzten Kapitel: Patche dort, wo der Name nachgeschlagen wird, nicht dort, wo er definiert ist. from emailer import send_email hat den Namen nach signup kopiert, also sucht register dort danach. Der falsche Weg — das ursprüngliche Modul zu patchen — lässt die Kopie unberührt, und die echte Funktion wird ausgeführt:
from unittest.mock import patch
from signup import register
def test_patched_in_the_wrong_place():
with patch("emailer.send_email") as send_email:
register("[email protected]")
send_email.assert_called_once()$ pytest -q --tb=line test_signup_wrong.py
F [100%]
=================================== FAILURES ===================================
E RuntimeError: real SMTP call — never in a test
/home/you/shop/emailer.py:2: RuntimeError: real SMTP call — never in a test
=========================== short test summary info ============================
FAILED test_signup_wrong.py::test_patched_in_the_wrong_place - RuntimeError: ...
1 failed in 0.08sDer Fehler stammt aus emailer.py — der echten Datei. Hier schlägt der Stellvertreter lautstark Alarm; ein echter E-Mail-Client hätte die E-Mail schlicht verschickt.
patch akzeptiert aus demselben Grund wie zuvor ebenfalls autospec=True: patch("signup.send_email", autospec=True) erzeugt einen Mock, der nur (to, subject) akzeptiert.
mocker — die pytest-mock-Fixture
Das Plugin pytest-mock bündelt all das in einer Fixture namens mocker. mocker.patch akzeptiert dieselben Argumente wie patch, benötigt weder Dekorator noch with und wird beim Ende des Tests automatisch rückgängig gemacht — genau wie monkeypatch. mocker.spy tanzt etwas aus der Reihe: Es ersetzt nichts. Die echte Methode wird ausgeführt; der Spy schaut nur zu.
from checkout import place_order
from fakes import FakeGateway, FakeMailer
from signup import register
def test_register_with_mocker(mocker):
send_email = mocker.patch("signup.send_email", autospec=True)
register("[email protected]")
send_email.assert_called_once_with("[email protected]", "Welcome aboard")
def test_spy_watches_a_real_object(mocker):
gateway = FakeGateway()
spy = mocker.spy(gateway, "charge")
place_order([{"price_cents": 700, "qty": 1}], "[email protected]", "tok", gateway, FakeMailer())
spy.assert_called_once_with(700, token="tok")
assert spy.spy_return == {"id": "ch_1"}
assert gateway.charges == [("ch_1", 700, "tok")]$ pytest -q test_signup_mocker.py
.. [100%]
2 passed in 0.08sDer Spy hat den Aufruf wie ein Mock aufgezeichnet, das echte Ergebnis in spy_return behalten, und das echte charge hat trotzdem seine Arbeit verrichtet — gateway.charges wurde befüllt. (FakeGateway definieren wir im nächsten Abschnitt.) mocker bietet außerdem mocker.Mock, mocker.MagicMock und mocker.create_autospec, sodass ein Test alles aus einer einzigen Fixture beziehen kann.
Stub, Mock, Fake — und wann man nicht mocken sollte
Drei Begriffe werden oft verwechselt, doch ihr Unterschied entscheidet darüber, welche Fehler dein Test überhaupt aufdecken kann:
- Ein Stub antwortet nur.
gateway.charge.return_value = {"id": "ch_1"}, und du prüfst nie, wie er aufgerufen wurde. Er füttert den zu testenden Code. - Ein Mock ist ein Stub, den du im Nachhinein befragst:
assert_called_once_with(...). Der Test dreht sich um den Aufruf selbst. - Ein Fake ist eine echte, funktionierende, kleine Implementierung — ein In-Memory-Gateway, das eine Liste führt, statt mit einer Bank zu kommunizieren.
Ein Test voller Mocks prüft, wie der Code gearbeitet hat. Übertreibst du es, prüft der Test nicht mehr, was der Code getan hat. Zuerst der falsche Weg:
import checkout
from checkout import place_order
def test_place_order_over_mocked(mocker):
mocker.patch("checkout.cart_total", return_value=2500)
gateway = mocker.Mock()
gateway.charge.return_value = {"id": "ch_1"}
mailer = mocker.Mock()
place_order([{"price_cents": 1, "qty": 1}], "[email protected]", "tok", gateway, mailer)
checkout.cart_total.assert_called_once()
gateway.charge.assert_called_once_with(2500, token="tok")
mailer.send.assert_called_once()$ pytest -q test_overmock.py
. [100%]
1 passed in 0.07sDer Test besteht. Der Warenkorb kostet einen Cent und der Test „bestätigt“ eine Abbuchung von 2500 — weil der Test selbst die Rechenlogik ersetzt hat. Baust du einen Fehler in cart_total ein, besteht der Test trotzdem. Benennst du cart_total um, ohne das Verhalten zu ändern, schlägt er fehl. Das ist das klassische Warnsignal für Over-Mocking: Ein Test bildet die Implementierung Zeile für Zeile nach, testet seine eigenen Mocks und bricht bei jedem Refactoring auseinander.
Der richtige Weg folgt dem Plan vom Anfang des Kapitels: Mocke an den Grenzen deines Systems, und nur dort. Der Payment-Provider und der SMTP-Server bilden diese Grenze. cart_total ist dein eigener Code — lass ihn laufen. Und bei Komponenten mit einer kleinen Schnittstelle schlägt ein Fake einen Mock fast immer:
from payments import PaymentDeclined
class FakeGateway:
"""An in-memory stand-in for PaymentGateway: same methods, no network."""
def __init__(self, declines=False):
self.declines = declines
self.charges = []
def charge(self, amount_cents, token):
if self.declines:
raise PaymentDeclined("card declined")
charge_id = f"ch_{len(self.charges) + 1}"
self.charges.append((charge_id, amount_cents, token))
return {"id": charge_id}
def refund(self, charge_id):
self.charges = [c for c in self.charges if c[0] != charge_id]
class FakeMailer:
def __init__(self):
self.sent = []
def send(self, to, subject):
self.sent.append((to, subject))Ein Test mit Fakes prüft den Zustand (State) per Assertion — was am Ende in der Liste gelandet ist — anstatt eine Abfolge von Aufrufen zu kontrollieren. Er liest sich wie eine Beschreibung des Verhaltens und übersteht jedes Refactoring, solange das Verhalten gleich bleibt. Du schreibst das Fake einmalig, in fakes.py oder als Fixture in conftest.py, und jeder Test verwendet es wieder. Seine einzige Schwachstelle: Ein Fake kann vom echten Client abweichen — weshalb das vollständige Beispiel einen einzelnen Autospec-Test für den Vertrag behält.
Ein vollständiges Beispiel
test_checkout.py setzt den Testplan Zeile für Zeile um, mit jedem Werkzeug genau dort, wo es hingehört — Fakes für das Verhalten, ein Autospec-Mock für den Vertrag, ein side_effect für den Fehlerfall:
from unittest.mock import create_autospec
import pytest
from checkout import place_order
from fakes import FakeGateway, FakeMailer
from payments import PaymentGateway
CART = [{"price_cents": 1000, "qty": 2}, {"price_cents": 500, "qty": 1}]
EMAIL = "[email protected]"
# Behaviour, checked with fakes: what happened, not which calls were made.
def test_paid_order_charges_once_and_sends_a_receipt():
gateway, mailer = FakeGateway(), FakeMailer()
result = place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert result == {"status": "paid", "charge_id": "ch_1", "total_cents": 2500}
assert gateway.charges == [("ch_1", 2500, "tok_visa")]
assert mailer.sent == [(EMAIL, "Order confirmed: ch_1")]
def test_declined_card_sends_an_apology_and_charges_nothing():
gateway, mailer = FakeGateway(declines=True), FakeMailer()
result = place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert result == {"status": "declined"}
assert gateway.charges == []
assert mailer.sent == [(EMAIL, "Your payment was declined")]
def test_empty_cart_never_reaches_the_gateway():
gateway, mailer = FakeGateway(), FakeMailer()
with pytest.raises(ValueError, match="cart is empty"):
place_order([], EMAIL, "tok_visa", gateway, mailer)
assert gateway.charges == []
assert mailer.sent == []
# The contract with the real client, checked with an autospec mock.
def test_gateway_is_called_with_the_real_signature():
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge.return_value = {"id": "ch_9"}
place_order(CART, EMAIL, "tok_visa", gateway, FakeMailer())
gateway.charge.assert_called_once_with(2500, token="tok_visa")
gateway.refund.assert_not_called()
def test_a_timeout_propagates_and_sends_nothing():
gateway = create_autospec(PaymentGateway, instance=True)
gateway.charge.side_effect = TimeoutError("gateway timed out")
mailer = FakeMailer()
with pytest.raises(TimeoutError):
place_order(CART, EMAIL, "tok_visa", gateway, mailer)
assert mailer.sent == []$ pytest -v test_checkout.py
============================= test session starts ==============================
collected 5 items
test_checkout.py::test_paid_order_charges_once_and_sends_a_receipt PASSED [ 20%]
test_checkout.py::test_declined_card_sends_an_apology_and_charges_nothing PASSED [ 40%]
test_checkout.py::test_empty_cart_never_reaches_the_gateway PASSED [ 60%]
test_checkout.py::test_gateway_is_called_with_the_real_signature PASSED [ 80%]
test_checkout.py::test_a_timeout_propagates_and_sends_nothing PASSED [100%]
============================== 5 passed in 0.08s ===============================Zwei Dinge sind bemerkenswert.
Erstens enthalten die drei Verhaltenstests überhaupt kein assert_called.... Sie prüfen das Ergebnis — die Rückgabe, die verbuchten Abbuchungen, die versendeten E-Mails — place_order könnte also von Grund auf neu geschrieben werden, und die Tests würden weiterhin gelten.
Zweitens ist der einzige Test, der einen Aufruf prüft, der Vertrag mit PaymentGateway, und er verwendet create_autospec. Wenn jemand charge umbenennt oder ein Pflichtargument hinzufügt, schlägt dieser Test fehl, anstatt stillschweigend zuzustimmen. Fünf Zeilen im Plan, fünf Tests, nichts davon testet das Innenleben von place_order.
Wenn etwas schiefgeht
AssertionError: expected call not found. Der Mock wurde aufgerufen, aber nicht mit diesen Argumenten. Die Meldung gibt Expected: und Actual: untereinander aus — vergleiche sie Zeichen für Zeichen. Ein Positionsargument gegenüber einem Schlüsselwortargument (charge(2500, "tok") gegenüber charge(2500, token="tok")) zählt als unterschiedlicher Aufruf.
AssertionError: Expected 'charge' to be called once. Called 2 times. Der Code hat die Methode mehr als einmal aufgerufen, und die Zeile Calls: listet jeden Aufruf auf. Häufig liegt das an einer Retry-Schleife oder daran, dass ein einzelner Mock über zwei Aktionen in einem Test hinweg wiederverwendet wurde.
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? Eine Spec macht ihre Arbeit: Der Code verwendet einen Namen, den die echte Klasse nicht besitzt. Repariere den Code, nicht den Test.
TypeError: missing a required argument: 'token' Ein Autospec-Mock hat einen Aufruf abgefangen, der nicht zur echten Signatur passt. Der Code würde beim echten Client auf genau dieselbe Weise fehlschlagen.
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' Das Ziel von patch benennt etwas, das nicht existiert — ein Tippfehler oder das falsche Modul. patch weigert sich, neue Attribute zu erfinden, und das ist auch gut so.
Der Patch hat „nichts bewirkt“ und der echte Dienst wurde aufgerufen Du hast dort gepatcht, wo der Name definiert ist, nicht dort, wo er nachgeschlagen wird. Wenn das Modul from emailer import send_email verwendet hat, patche "signup.send_email".
StopIteration bei einem Mock-Aufruf side_effect war eine Liste, und der Code hat mehr Aufrufe ausgeführt, als die Liste Elemente hatte.
Ein Ergebnis enthält <MagicMock name='mock.charge().__getitem__()' ...> statt Daten Du hast return_value nicht gesetzt, und ein MagicMock hat receipt["id"] bereitwillig mit einem weiteren Mock beantwortet. Bei einem einfachen Mock schlägt derselbe Code mit TypeError: 'Mock' object is not subscriptable fehl. Setze return_value auf die Struktur, die der echte Client zurückgibt.
fixture 'mocker' not found pytest-mock ist in der Umgebung, aus der pytest ausgeführt wird, nicht installiert. Führe pip install pytest-mock aus.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
side_effect ist eine Liste mit drei Elementen, das mittlere ist eine Exception (Ausnahme). Was genau wird ausgegeben?
from unittest.mock import Mock
client = Mock()
client.fetch.side_effect = [1, ValueError("boom"), 3]
print(client.fetch())
try:
client.fetch()
except ValueError as exc:
print("error:", exc)
print(client.fetch(), client.fetch.call_count)- A1 error: boom 3 2
- B1 error: boom 3 3
- C[1, ValueError('boom'), 3] error: boom 3 1
- D1 ValueError('boom') 3 3
Der Test schlägt mit RuntimeError: real SMTP call fehl — das echte send wurde ausgeführt. Welche Änderung behebt das?
# mailer.py
def send(to, subject):
raise RuntimeError("real SMTP call")
# orders.py
from mailer import send
def confirm(order_id, email):
send(email, f"Order {order_id} confirmed")
# test_orders.py
from orders import confirm
def test_confirm_sends_one_email(mocker):
send = mocker.patch("mailer.send")
confirm(7, "[email protected]")
send.assert_called_once_with("[email protected]", "Order 7 confirmed")- AVerwende `mocker.spy(mailer, "send")` statt `mocker.patch`
- BFüge `import mailer` zu `orders.py` hinzu, bevor `send` aufgerufen wird
- CSchreibe `mocker.patch("orders.send")` — dort, wo `orders` den Namen nachschlägt
- DSchreibe `mocker.patch("mailer.send", autospec=True)`
pay vergisst das Token. Ein Test mit welchem Stellvertreter fängt diesen Fehler ab?
class PaymentGateway:
def charge(self, amount_cents, token):
...
def pay(gateway, total):
return gateway.charge(total) # the token is forgotten- Acreate_autospec(PaymentGateway, instance=True)
- BMagicMock()
- CMock()
- DMock(spec=PaymentGateway)
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Schreibe weather.py mit einer Klasse WeatherClient, deren Methode get_forecast(self, city) einen RuntimeError auslöst — sie steht stellvertretend für den echten HTTP-Client — und einer Funktion umbrella_advice(city, client). Die Funktion ruft client.get_forecast(city) auf, was ein Dictionary wie {"rain_chance": 70} zurückgibt, und liefert „take an umbrella“ zurück, wenn die Wahrscheinlichkeit bei 50 oder mehr liegt, andernfalls „leave it at home“. Wenn der Client einen TimeoutError auslöst, gibt sie „forecast unavailable“ zurück.
Beginne mit einem Testplan (Fall | Eingabe | Erwartet) und schreibe dann test_weather.py:
- Teste mit
create_autospec(WeatherClient, instance=True)undreturn_valuebeide Ratschläge — einschließlich des Grenzwerts bei exakt 50 — und prüfe mitassert_called_once_with, dass die Stadt übergeben wurde. - Teste mit
side_effect = TimeoutError(...)den Pfad für „forecast unavailable“. - Setze
side_effectauf eine Liste, um zwei unterschiedliche Antworten von einem einzigen Mock zu erhalten. - Schreibe einen
FakeWeatherClient, der ein Dictionary von Stadt → Regenwahrscheinlichkeit bereithält, und teste damit.
Danach ein Experiment: Tue so, als wäre die Bibliothek aktualisiert worden, und ändere die echte Methode in get_forecast(self, city, units). Führe einen Test aus, der Mock() verwendet, und einen, der create_autospec nutzt. Welcher von beiden bemerkt den Unterschied?
Lösung
weather.py:
class WeatherClient:
"""Stands for the real HTTP client."""
def get_forecast(self, city):
raise RuntimeError("real HTTP call — never in a test")
def umbrella_advice(city, client):
try:
forecast = client.get_forecast(city)
except TimeoutError:
return "forecast unavailable"
if forecast["rain_chance"] >= 50:
return "take an umbrella"
return "leave it at home"Der Plan:
| Fall | Eingabe | Erwartet | |---|---|---| | Regnerisch | Wahrscheinlichkeit 70 | „take an umbrella“ | | Grenzwert | Wahrscheinlichkeit 50 | „take an umbrella“ | | Knapp darunter | Wahrscheinlichkeit 49 | „leave it at home“ | | Netzwerkfehler | Client löst TimeoutError aus | „forecast unavailable“ | | Vertrag | beliebige Stadt | get_forecast wird einmal mit dieser Stadt aufgerufen |
test_weather.py:
from unittest.mock import create_autospec
import pytest
from weather import WeatherClient, umbrella_advice
@pytest.fixture
def client():
return create_autospec(WeatherClient, instance=True)
@pytest.mark.parametrize(
"chance, advice",
[(70, "take an umbrella"), (50, "take an umbrella"), (49, "leave it at home")],
)
def test_advice_follows_the_rain_chance(client, chance, advice):
client.get_forecast.return_value = {"rain_chance": chance}
assert umbrella_advice("Dhaka", client) == advice
client.get_forecast.assert_called_once_with("Dhaka")
def test_timeout_gives_a_polite_answer(client):
client.get_forecast.side_effect = TimeoutError("no answer in 5s")
assert umbrella_advice("Dhaka", client) == "forecast unavailable"
def test_one_mock_two_answers(client):
client.get_forecast.side_effect = [{"rain_chance": 90}, {"rain_chance": 10}]
assert umbrella_advice("Dhaka", client) == "take an umbrella"
assert umbrella_advice("Dhaka", client) == "leave it at home"
assert client.get_forecast.call_count == 2
class FakeWeatherClient:
def __init__(self, chances):
self.chances = chances
def get_forecast(self, city):
return {"rain_chance": self.chances[city]}
def test_with_a_fake():
client = FakeWeatherClient({"Dhaka": 80, "Cairo": 5})
assert umbrella_advice("Dhaka", client) == "take an umbrella"
assert umbrella_advice("Cairo", client) == "leave it at home"$ pytest -v test_weather.py
============================= test session starts ==============================
collected 6 items
test_weather.py::test_advice_follows_the_rain_chance[70-take an umbrella] PASSED [ 16%]
test_weather.py::test_advice_follows_the_rain_chance[50-take an umbrella] PASSED [ 33%]
test_weather.py::test_advice_follows_the_rain_chance[49-leave it at home] PASSED [ 50%]
test_weather.py::test_timeout_gives_a_polite_answer PASSED [ 66%]
test_weather.py::test_one_mock_two_answers PASSED [ 83%]
test_weather.py::test_with_a_fake PASSED [100%]
============================== 6 passed in 0.08s ===============================Das Experiment mit get_forecast(self, city, units) in WeatherClient und dieser test_drift.py:
from unittest.mock import Mock, create_autospec
from weather import WeatherClient, umbrella_advice
def test_plain_mock():
client = Mock()
client.get_forecast.return_value = {"rain_chance": 70}
assert umbrella_advice("Dhaka", client) == "take an umbrella"
def test_autospec():
client = create_autospec(WeatherClient, instance=True)
client.get_forecast.return_value = {"rain_chance": 70}
assert umbrella_advice("Dhaka", client) == "take an umbrella"$ pytest -q --tb=line test_drift.py
.F [100%]
=================================== FAILURES ===================================
E TypeError: missing a required argument: 'units'
/usr/lib/python3.12/inspect.py:3157: TypeError: missing a required argument: 'units'
=========================== short test summary info ============================
FAILED test_drift.py::test_autospec - TypeError: missing a required argument:...
1 failed, 1 passed in 0.07sDie Gründe für die einzelnen Entscheidungen:
- Eine Fixture baut den Autospec-Mock. Jeder Test erhält eine frische Instanz, sodass Aufrufzähler niemals von einem Test zum nächsten durchsickern und die strenge Version die Voreinstellung ist, statt etwas, woran man sich eigens erinnern muss.
parametrizedeckt 70, 50 und 49 ab. Das>=ist die Stelle, an der sich am ehesten ein Fehler einschleicht; 50 und 49 liegen direkt beiderseits der Grenze. Ein einzelner „Regen“-Fall würde auch mit>bestehen.assert_called_once_with("Dhaka")liegt im parametrisierten Test, da die Weitergabe der Stadt Teil des Vertrags mit dem Client ist — der eine Aufruf, der die Systemgrenze überschreitet.- Der Timeout-Test prüft nur den Rückgabewert. Entscheidend ist, was der Benutzer sieht; wie oft der Client angefragt wurde, gehört nicht zum Versprechen.
- Der Fake benötigt kein Setup pro Test: Du beschreibst die Welt (
{"Dhaka": 80, "Cairo": 5}) und stellst Fragen an sie. Bei einem Client, den du selbst besitzt, ist das meist der lesbarere Test. - Das Experiment zeigt den Preis eines einfachen
Mock: Nach dem Upgrade würde der echte Client bei jedem Aufruf fehlschlagen, und der Test mit dem einfachen Mock bleibt grün. Der Autospec-Test schlägt noch am selben Tag fehl und benennt das fehlende Argument.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen