Mocking — controleren hoe een externe service is aangeroepen
Zet een Mock in als vervanger voor een betaal- of e-mailclient en controleer hoe deze is aangeroepen. return_value, side_effect, autospec, patch, mocker — en wanneer een fake beter is dan een mock.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een klein afrekensysteem. Het berekent het totaal van een winkelmandje, schrijft via een betaalclient een bedrag af van een creditcard en stuurt de klant een e-mail.
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}In de echte wereld schrijft charge geld af van een echte kaart, dus een test mag die methode nooit bereiken. In het vorige hoofdstuk stelde monkeypatch je in staat om iets te vervangen — maar vervangen is hier slechts het halve werk. De retourwaarde van place_order zegt "paid", terwijl de bugs die er echt toe doen in de aanroepen zitten: werd er 2500 of 25 afgeschreven? Met welk token? Eén keer, of twee keer? Ging de e-mail naar het juiste adres? Om die vragen te beantwoorden, moet de stand-in onthouden hoe deze is aangeroepen.
Je zou daarvoor zelf een kleine klasse kunnen schrijven die de aanroepen vastlegt, één per afhankelijkheid. unittest.mock heeft dit al voor je gedaan.
Aan het einde van dit hoofdstuk kun je
Mockgebruiken als stand-in voor een client en controleren hoe deze is aangeroepen metassert_called_once_with,call_args,call_countenmock_calls- Een mock waarden laten retourneren of exceptions laten opwerpen met
return_valueenside_effect - Uitleggen waarom een kale
Mockgevaarlijk is, en dat gat dichten metspec=encreate_autospec - Een naam op de juiste plek vervangen met
patch, als decorator en als contextmanager mocker.patchenmocker.spyvan pytest-mock gebruiken- Het onderscheid maken tussen een stub, een mock en een fake, en weten wanneer een fake de betere test oplevert
Voorkennis: monkeypatch — zaken vervangen voor één test.
Voordat je de test schrijft
Voordat er ook maar een Mock verschijnt, schrijf je op wat place_order daadwerkelijk belooft. Het heeft één invoer die jij beheert (het winkelmandje, e-mailadres en token) en twee neveneffecten (side effects) die je niet terugziet in de retourwaarde — een afschrijving en een e-mail. Een test voor dit soort code moet beide helften controleren.
Het contract. Gegeven een niet-leeg winkelmandje schrijft place_order exact het totaal af, één keer, met het opgegeven token; stuurt een bevestigingsmail; retourneert "paid". Wordt de kaart geweigerd, dan wordt er niets afgeschreven, stuurt de functie een excusesmail en retourneert "declined". Een leeg winkelmandje werpt een ValueError op voordat er ook maar iets de deur uit gaat. Een netwerkfout wordt niet stilzwijgend genegeerd.
De voorbereiding. pytest in je virtuele omgeving, payments.py en checkout.py importeerbaar vanaf de plek waar je pytest uitvoert, en de pytest-mock-plugin voor de mocker-fixture (pip install pytest-mock). unittest.mock wordt standaard met Python meegeleverd. Geen API-sleutel, geen netwerk — als een test een van beide nodig heeft, is het geen unit test.
Het plan.
| Geval | Invoer | Verwacht | |---|---|---| | Happy path | 2 × 1000 + 1 × 500, kaart geaccepteerd | één afschrijving van 2500 met tok_visa; bevestigingsmail; "paid" | | Geweigerde kaart | zelfde winkelmandje, gateway werpt PaymentDeclined op | geen afschrijving vastgelegd; excusesmail; "declined" | | Leeg winkelmandje | [] | ValueError("cart is empty"); gateway en mailer onaangeroerd | | Netwerkfout | gateway werpt TimeoutError op | TimeoutError propageert; geen e-mail | | Contract | willekeurige bestelling | charge aangeroepen met de echte signatuur (amount_cents, token) |
Wat je niet moet testen. De betalingsprovider zelf — dat is de taak van de makers ervan, en je test kan er toch niet bij. Pythons sum. En de interne stappen van place_order: of het cart_total één keer aanroept of de berekening inline uitvoert, is zijn eigen zaak. Het plan vermeldt uitkomsten en de ene aanroep die de grens naar buiten overschrijdt — niets anders. Onthoud dat goed; het is het verschil tussen een nuttige mock en een schadelijke mock, en aan het einde van dit hoofdstuk komen we hierop terug.
Mock — een object dat elke aanroep vastlegt
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 heeft geen charge-methode; er was niets gedefinieerd. Een Mock verzint elk attribuut dat je aanraakt, en elk attribuut is zelf een Mock die kan worden aangeroepen. Elke aanroep wordt vastgelegd: called geeft aan of het is gebeurd, call_count hoe vaak, call_args met exact welke argumenten. mock_calls op het bovenliggende object bevat elke aanroep naar elke methode, op volgorde — handig wanneer de volgorde van belang is.
Die attributen uitlezen in een gewone assert werkt, maar de assertion-methoden doen het in één regel en geven uitleg wanneer ze falen:
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 controleert twee dingen: er was precies één aanroep, en die had exact deze argumenten. Verwanten hiervan zijn assert_called_once() (één keer, met willekeurige argumenten), assert_called_with(...) (alleen de laatste aanroep) en assert_not_called(). (Tracebacks in dit hoofdstuk zijn ingekort met ... waar alleen de laatste regel van belang is.)
Waarom "once"? Omdat het twee keer afschrijven van een kaart een serieuze bug is, en assert_called_with alleen een tweede aanroep niet zou opmerken.
return_value en side_effect — wat de mock teruggeeft
Standaard retourneert een aanroep een andere Mock. Code die receipt["id"] uitvoert heeft een echt antwoord nodig, dus geef de mock er een:
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'}Hetzelfde antwoord, ongeacht de argumenten. Wanneer dat niet genoeg is, stel je side_effect in. Dit heeft drie vormen.
Een exception — gateway.charge.side_effect = PaymentDeclined("card declined") zorgt ervoor dat elke aanroep deze opwerpt. Zo bereik je de unhappy paths die in werkelijkheid lastig na te bootsen zijn: een geweigerde kaart, een time-out, een 500-fout van de server.
Een lijst — één element per aanroep, op volgorde. Een exception in de lijst wordt opgeworpen; al het andere wordt geretourneerd. Handig voor "faalt één keer, slaagt daarna":
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):
...
StopIterationEen derde aanroep trof de lijst leeg aan. StopIteration vanuit een mock betekent dat de code vaker is aangeroepen dan je had gepland — wat op zichzelf waardevol is om te weten.
Een functie — aangeroepen met dezelfde argumenten als de mock, en wat de functie retourneert is het antwoord. gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"} levert een antwoord op dat afhangt van de invoer — en de aanroepen worden nog steeds vastgelegd en geteld.
MagicMock — wanneer de code len, with of for gebruikt
Een gewone Mock ondersteunt Pythons speciale methoden niet: len(Mock()) werpt TypeError: object of type 'Mock' has no len() op, en with Mock(): faalt eveneens. MagicMock ondersteunt ze wel, met verstandige standaardwaarden — len(MagicMock()) is 0, het werkt in een with-blok, en m.__len__.return_value = 3 past het antwoord aan.
Grijp naar MagicMock wanneer de stand-in wordt gebruikt als contextmanager, container of iterator — zoals een databaseverbinding in een with-blok. patch en mocker.patch, hieronder, maken standaard MagicMocks aan.
Het gevaar: een Mock zegt overal ja op
Die meegaande aard heeft een prijs. Maak een typfout in een methodenaam, en niets klaagt:
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')]Als die typfout in checkout.py zou zitten, zou een test met een kale Mock gewoon slagen, waarna de eerste echte klant een AttributeError voor zijn kiezen krijgt. Verkeerde argumenten glippen er op dezelfde manier tussendoor. Hier zie je de verkeerde en de juiste manier naast elkaar — de geteste code vergeet het 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.07sDe gewone Mock liet een kapotte aanroep door — een groene test die niets beschermt. create_autospec bouwt een mock op basis van de echte PaymentGateway: deze bevat alleen de attributen van die klasse, en elke methode controleert zijn argumenten tegen de echte signatuur. instance=True betekent "een instantie van de klasse", niet de klasse zelf.
Drie niveaus van striktheid:
Mock()— willekeurige attributen, willekeurige argumenten.Mock(spec=PaymentGateway)— alleen echte attribuutnamen (gateway.chragewerptAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?op), maar argumenten worden niet gecontroleerd.create_autospec(PaymentGateway, instance=True), ofautospec=Truebijpatch— echte namen en echte signaturen.
Waarom maakt dit uit, los van typfouten? Omdat de echte client verandert. Wanneer een update van een bibliotheek een methode hernoemt of een verplicht argument toevoegt, faalt een autospec-mock direct dezelfde dag nog; een kale Mock blijft akkoord gaan met code die allang niet meer werkt. Gebruik voor alles wat optreedt als vervanger van een echte klasse het striktste niveau.
Python beschermt zelf tegen één soort typfout: sinds versie 3.12 werpt een verkeerd gespelde assertion zoals assert_called_once_wiht een AttributeError: 'assert_called_once_wiht' is not a valid assertion. op, in plaats van stilletjes te slagen. Die beveiliging kent alleen de eigen assertion-namen van de mock. Het kan niet weten dat jouw gateway charge heeft en niet chrage — dat kan alleen een spec.
patch — een naam vervangen die de code zelf opzoekt
place_order ontvangt zijn gateway als argument, dus een mock meegeven is eenvoudig. Vaak haalt de code zijn afhankelijkheid echter zelf op:
# 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 vervangt een naam door een MagicMock voor de duur van een test, en zet het origineel daarna weer terug. Het werkt als decorator — de mock wordt meegeleverd als argument — of als contextmanager:
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.08sHet doelwit is "signup.send_email", niet "emailer.send_email". Dat is de regel uit het vorige hoofdstuk: patch waar de naam wordt opgezocht, niet waar deze is gedefinieerd. from emailer import send_email heeft de naam naar signup gekopieerd, dus register zoekt daar. De verkeerde manier — de oorspronkelijke module patchen — laat de kopie ongemoeid, waardoor de echte functie draait:
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.08sDe error is afkomstig uit emailer.py — de echte. Hier werpt de stand-in luidruchtig een exception op; een echte e-mailclient had simpelweg de e-mail verstuurd.
patch accepteert ook autospec=True, om dezelfde reden als eerder: patch("signup.send_email", autospec=True) maakt een mock die alleen (to, subject) accepteert.
mocker — de pytest-mock-fixture
De plugin pytest-mock verpakt dit alles in een fixture genaamd mocker. mocker.patch accepteert dezelfde argumenten als patch, heeft geen decorator of with nodig, en wordt automatisch ongedaan gemaakt wanneer de test eindigt — net zoals monkeypatch. mocker.spy is de vreemde eend in de bijt: het vervangt niets. De echte methode draait; de spy kijkt alleen toe.
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.08sDe spy legde de aanroep vast zoals een mock, bewaarde het echte resultaat in spy_return, en de echte charge deed alsnog zijn werk — gateway.charges werd gevuld. (FakeGateway wordt in de volgende sectie gedefinieerd.) mocker biedt ook mocker.Mock, mocker.MagicMock en mocker.create_autospec, zodat een test alles uit één fixture kan halen.
Stub, mock, fake — en wanneer je niet moet mocken
Drie termen worden vaak door elkaar gehaald, en het verschil bepaalt wat je test kan opvangen:
- Een stub geeft alleen antwoord.
gateway.charge.return_value = {"id": "ch_1"}, en je controleert nooit hoe het is aangeroepen. Het voedt de code die wordt getest. - Een mock is een stub die je achteraf ondervraagt:
assert_called_once_with(...). De test draait om de aanroep zelf. - Een fake is een echte, werkende, kleine implementatie — een in-memory gateway die een lijst bijhoudt in plaats van met een bank te communiceren.
Een test die uit mocks bestaat controleert hoe de code zijn werk deed. Schiet je daarin door, dan controleert de test niet langer wat de code deed. Eerst de verkeerde manier:
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.07sHij slaagt. Het winkelmandje kost één cent, en de test "bevestigt" een afschrijving van 2500 — omdat de test zelf de rekensom heeft vervangen. Sloop cart_total en deze test slaagt nog steeds. Hernoem cart_total zonder gedrag te veranderen en de test faalt. Dat is de code smell van over-mocking: een test die de implementatie regel voor regel spiegelt, zijn eigen mocks test en bij elke refactoring breekt.
De juiste manier volgt het plan vanaf het begin van dit hoofdstuk: mock aan de rand van je systeem, en alleen daar. De betalingsprovider en de SMTP-server vormen die rand. cart_total is je eigen code — laat die gewoon draaien. En voor samenwerkende onderdelen met een kleine interface wint een fake het meestal van een mock:
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))Een test met fakes doet assertions op toestand (state) — wat er uiteindelijk in de lijst terecht is gekomen — in plaats van op een reeks aanroepen. Het leest als een beschrijving van het gedrag, en het overleeft een refactoring zolang het gedrag intact blijft. Je schrijft de fake één keer, in fakes.py of als fixture in conftest.py, en elke test hergebruikt hem. De enige zwakke plek is dat een fake kan gaan afwijken (driften) van de echte client — en dat is waarom het complete voorbeeld één autospec-test behoudt voor het contract.
Een compleet voorbeeld
test_checkout.py implementeert het testplan regel voor regel, met elk hulpmiddel op de plek waar het hoort — fakes voor gedrag, een autospec-mock voor het contract, een side_effect voor de fout:
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 ===============================Twee dingen vallen hierbij op.
Ten eerste bevatten de drie gedragstests helemaal geen assert_called.... Ze controleren de uitkomst — het resultaat, de vastgelegde afschrijvingen, de verstuurde e-mails — waardoor place_order vanaf nul herschreven zou kunnen worden terwijl de tests nog steeds geldig blijven.
Ten tweede is de enige test die een aanroep controleert het contract met PaymentGateway, en die maakt gebruik van create_autospec. Als iemand charge hernoemt of een verplicht argument toevoegt, faalt die test in plaats van geruisloos akkoord te gaan. Vijf rijen in het plan, vijf tests, en niets dat het binnenwerk van place_order test.
Wanneer het misgaat
AssertionError: expected call not found. De mock is wel aangeroepen, maar niet met deze argumenten. Het bericht print Expected: en Actual: onder elkaar — vergelijk ze teken voor teken. Een positioneel argument vergeleken met een keyword-argument (charge(2500, "tok") versus charge(2500, token="tok")) geldt als een andere aanroep.
AssertionError: Expected 'charge' to be called once. Called 2 times. De code heeft de methode vaker dan één keer aangeroepen, en de regel Calls: toont elke aanroep. Dit is vaak een retry-lus, of één mock die over twee acties binnen een test wordt hergebruikt.
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? Een spec die zijn werk doet: de code gebruikt een naam die de echte klasse niet heeft. Repareer de code, niet de test.
TypeError: missing a required argument: 'token' Een autospec-mock heeft een aanroep onderschept die niet overeenkomt met de echte signatuur. De code zou bij de echte client op precies dezelfde manier falen.
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' Het doelwit van patch noemt iets wat niet bestaat — een typfout, of de verkeerde module. patch weigert nieuwe attributen te verzinnen, en dat is maar goed ook.
De patch "deed niets" en de echte service werd aangeroepen Je hebt gepatched waar de naam is gedefinieerd, niet waar deze wordt opgezocht. Als de module from emailer import send_email deed, patch dan "signup.send_email".
StopIteration bij een mock-aanroep side_effect was een lijst, en de code deed meer aanroepen dan de lijst aan elementen had.
Een resultaat bevat <MagicMock name='mock.charge().__getitem__()' ...> in plaats van data Je hebt return_value nooit ingesteld, en een MagicMock beantwoordde receipt["id"] vrolijk met weer een andere mock. Met een gewone Mock faalt dezelfde code met TypeError: 'Mock' object is not subscriptable. Stel return_value in op de structuur die de echte client retourneert.
fixture 'mocker' not found pytest-mock is niet geïnstalleerd in de virtuele omgeving waaruit pytest draait. pip install pytest-mock.
Stap 4 van 6 — Voorspellen
Test je begrip
side_effect is een lijst van drie elementen, waarvan de middelste een exception is. Wat wordt er precies geprint?
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
De test faalt met RuntimeError: real SMTP call — de echte send is uitgevoerd. Welke wijziging lost dit op?
# 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")- AGebruik `mocker.spy(mailer, "send")` in plaats van `mocker.patch`
- BVoeg `import mailer` toe aan `orders.py` vóór het aanroepen van `send`
- CSchrijf `mocker.patch("orders.send")` — waar `orders` de naam opzoekt
- DSchrijf `mocker.patch("mailer.send", autospec=True)`
pay vergeet het token. Een test met welke stand-in ontdekt deze bug?
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)
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf weather.py met een klasse WeatherClient waarvan de methode get_forecast(self, city) een RuntimeError opwerpt — deze staat voor de echte HTTP-client — en een functie umbrella_advice(city, client). De functie roept client.get_forecast(city) aan, wat een dictionary zoals {"rain_chance": 70} retourneert, en retourneert "take an umbrella" wanneer de kans 50 of hoger is, en anders "leave it at home". Als de client een TimeoutError opwerpt, retourneert de functie "forecast unavailable".
Begin met een testplan (geval | invoer | verwacht), en schrijf vervolgens test_weather.py:
- Test met
create_autospec(WeatherClient, instance=True)enreturn_valuebeide adviezen — inclusief het grenspunt bij exact 50 — en controleer metassert_called_once_withof de stad is doorgegeven. - Test met
side_effect = TimeoutError(...)het"forecast unavailable"-pad. - Haal met
side_effectingesteld op een lijst twee verschillende antwoorden op uit één mock. - Schrijf een
FakeWeatherClientdie een dictionary van city → rain chance bevat, en test daarmee.
Doe daarna één experiment: doe alsof de bibliotheek is geüpgraded en verander de echte methode in get_forecast(self, city, units). Voer een test uit die Mock() gebruikt en een test die create_autospec gebruikt. Welke van de twee merkt het op?
Oplossing
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"Het plan:
| Geval | Invoer | Verwacht | |---|---|---| | Regenachtig | kans 70 | "take an umbrella" | | Grensgeval | kans 50 | "take an umbrella" | | Net eronder | kans 49 | "leave it at home" | | Netwerkfout | client werpt TimeoutError op | "forecast unavailable" | | Contract | willekeurige stad | get_forecast één keer aangeroepen met die stad |
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 ===============================Het experiment, met get_forecast(self, city, units) in WeatherClient en dit 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.07sDe reden achter elke beslissing:
- Een fixture bouwt de autospec-mock. Elke test krijgt een nieuwe instantie, zodat aantallen aanroepen nooit van de ene test naar de volgende lekken, en de strikte versie de standaard is in plaats van iets wat je moet onthouden.
parametrizedekt 70, 50 en 49 af. De>=is de regel die het vaakst fout gaat; 50 en 49 zitten aan weerszijden daarvan. Een enkel "regenachtig" geval zou ook slagen met>.assert_called_once_with("Dhaka")staat in de geparametriseerde test, omdat het doorgeven van de stad onderdeel is van het contract met de client — de ene aanroep die de grens overschrijdt.- De time-outtest controleert alleen de retourwaarde. Wat telt is wat de gebruiker te zien krijgt; hoe vaak de client is geraadpleegd maakt geen deel uit van de belofte.
- De fake vereist geen opzet per test: je beschrijft de wereld (
{"Dhaka": 80, "Cairo": 5}) en stelt daar vragen over. Voor een client in eigen beheer levert dat meestal de best leesbare test op. - Het experiment laat de kosten van een kale
Mockzien: na de upgrade zou de echte client bij elke aanroep falen, terwijl de test met de gewone mock groen blijft. De autospec-test faalt dezelfde dag nog, waarbij het ontbrekende argument bij naam wordt genoemd.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken