Mocking — controllare come viene chiamato un servizio esterno
Metti un Mock al posto di un client di pagamento o di email e controlla come è stato chiamato. return_value, side_effect, autospec, patch, mocker — e quando un fake batte un mock.
Come vuoi imparare?
Il problema che stiamo risolvendo
Ecco un piccolo checkout. Somma il carrello, addebita una carta tramite un client di pagamento e manda un'email al cliente.
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}Nella vita reale charge preleva denaro da una carta vera, quindi un test non deve mai arrivarci. Nel capitolo precedente monkeypatch ti ha permesso di sostituire una cosa — ma qui sostituirla è solo la metà del lavoro. Il valore di ritorno di place_order dice "paid", mentre i bug che contanno stanno nelle chiamate: la carta è stata addebitata di 2500 o di 25? Con quale token? Una volta o due? L'email è stata inviata all'indirizzo giusto? Per rispondere, il sostituto deve ricordare come è stato chiamato.
Potresti scrivere per questo una piccola classe che registra le chiamate, una per ciascuna dipendenza. unittest.mock l'ha già scritta per te.
Alla fine di questo capitolo sarai in grado di
- Usare
Mockcome sostituto di un client e controllare come è stato chiamato conassert_called_once_with,call_args,call_countemock_calls - Far restituire valori o sollevare eccezioni a un mock con
return_valueeside_effect - Spiegare perché un
Mockcosì com'è è pericoloso, e chiudere la falla conspec=ecreate_autospec - Sostituire un nome con
patch, come decoratore e come context manager, nel punto giusto - Usare
mocker.patchemocker.spydi pytest-mock - Distinguere uno stub, un mock e un fake, e sapere quando un fake è il test migliore
Prerequisiti: monkeypatch — sostituire cose per un singolo test.
Prima di scrivere il test
Prima che appaia alcun Mock, scrivi cosa place_order promette davvero. Ha un input che controlli tu (il carrello, l'email e il token) e due effetti collaterali che non vedi nel suo valore di ritorno — un addebito e un'email. Un test per codice come questo deve controllare entrambe le metà.
Il contratto. Dato un carrello non vuoto, place_order addebita esattamente il totale, una sola volta, con il token dato; manda una email di conferma; restituisce "paid". Se la carta viene rifiutata, non addebita nulla, manda una email di scuse e restituisce "declined". Un carrello vuoto solleva ValueError prima che qualcosa esca dall'edificio. Un errore di rete non viene silenziato.
La preparazione. pytest nel tuo ambiente virtuale, payments.py e checkout.py importabili da dove esegui pytest, e il plugin pytest-mock per la fixture mocker (pip install pytest-mock). unittest.mock è fornito con Python. Nessuna chiave API, nessuna rete — se un test ha bisogno di uno dei due, non è un unit test.
Il piano.
| Caso | Input | Valore atteso | |---|---|---| | Happy path | 2 × 1000 + 1 × 500, carta accettata | un addebito di 2500 con tok_visa; email di conferma; "paid" | | Carta rifiutata | stesso carrello, il gateway solleva PaymentDeclined | nessun addebito registrato; email di scuse; "declined" | | Carrello vuoto | [] | ValueError("cart is empty"); gateway e mailer intatti | | Errore di rete | il gateway solleva TimeoutError | TimeoutError si propaga; nessuna email | | Contratto | qualsiasi ordine | charge chiamato con la firma reale (amount_cents, token) |
Cosa non testare. Il fornitore di pagamenti in sé — quello è compito dei suoi autori, e il tuo test non può arrivarci comunque. La sum di Python. E i passi interni di place_order: se chiama cart_total una volta o inlinea il calcolo sono affari suoi. Il piano elenca gli esiti e la singola chiamata che attraversa il confine — nient'altro. Tienilo a mente; è ciò che distingue un mock utile da uno dannoso, e alla fine del capitolo ci torniamo.
Mock — un oggetto che registra ogni chiamata
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 non ha alcun metodo charge; non è stato definito nulla. Un Mock inventa qualsiasi attributo tu tocchi, e ogni attributo è a sua volta un Mock che può essere chiamato. Ogni chiamata viene registrata: called dice se è avvenuta, call_count quante volte, call_args con quali argomenti esatti. mock_calls del genitore contiene ogni chiamata a ogni metodo, in ordine — utile quando l'ordine conta.
Leggere quegli attributi in un assert semplice funziona, ma i metodi di asserzione lo dicono in una riga e si spiegano da soli quando falliscono:
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 controlla due cose: che ci sia stata esattamente una chiamata, e che abbia avuto esattamente questi argomenti. I suoi parenti sono assert_called_once() (una volta, qualsiasi argomento), assert_called_with(...) (solo l'ultima chiamata) e assert_not_called(). (Gli traceback di questo capitolo sono ridotti a ... dove conta solo l'ultima riga.)
Perché "una volta"? Perché addebitare una carta due volte è un bug vero, e assert_called_with da solo non si accorgerebbe di una seconda chiamata.
return_value e side_effect — ciò che il mock restituisce
Per impostazione predefinita una chiamata restituisce un altro Mock. Il codice che fa receipt["id"] ha bisogno di una risposta vera, quindi dargliene una:
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'}La stessa risposta, qualunque siano gli argomenti. Quando non basta, imposta side_effect. Ha tre forme.
Un'eccezione — gateway.charge.side_effect = PaymentDeclined("card declined") fa sollevare quell'eccezione a ogni chiamata. È così che raggiungi i percorsi d'errore che è difficile causare davvero: una carta rifiutata, un timeout, un 500 dal server.
Una lista — un elemento per chiamata, in ordine. Un'eccezione nella lista viene sollevata; qualsiasi altro elemento viene restituito. Utile per "fallisce una volta, poi ha successo":
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):
...
StopIterationUna terza chiamata ha trovato la lista vuota. Un StopIteration da un mock significa che il codice lo ha chiamato più spesso di quanto previsto — il che di per sé vale la pena saperlo.
Una funzione — chiamata con gli stessi argomenti del mock, e ciò che restituisce è la risposta. gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"} fornisce una risposta che dipende dall'input — e le chiamate vengono comunque registrate e contate.
MagicMock — quando il codice usa len, with o for
Un Mock semplice non supporta i metodi speciali di Python: len(Mock()) solleva TypeError: object of type 'Mock' has no len(), e anche with Mock(): fallisce. MagicMock li supporta con valori predefiniti sensati — len(MagicMock()) è 0, funziona in un blocco with, e m.__len__.return_value = 3 cambia la risposta.
Ricorri a MagicMock quando il sostituto viene usato come context manager, contenitore o iteratore — una connessione a un database in un blocco with, per esempio. patch e mocker.patch, più sotto, creano MagicMock per impostazione predefinita.
Il pericolo: un Mock dice sì a tutto
Quella natura accondiscendente ha un prezzo. Scrivi male un metodo, e nessuno si lamenta:
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')]Se quell'errore di battitura fosse in checkout.py, un test con un Mock così com'è potrebbe passare, e il primo cliente reale riceverebbe un AttributeError. Anche argomenti sbagliati scivolano allo stesso modo. Ecco il modo sbagliato e il modo giusto affiancati — il codice sotto test dimentica il 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.07sIl Mock semplice ha lasciato passare una chiamata rotta — un test verde che non protegge nulla. create_autospec costruisce un mock dalla vera PaymentGateway: ha solo gli attributi di quella classe, e ogni metodo controlla i propri argomenti contro la firma reale. instance=True significa "un'istanza della classe", non la classe stessa.
Tre livelli di rigore:
Mock()— qualsiasi attributo, qualsiasi argomento.Mock(spec=PaymentGateway)— solo nomi di attributi reali (gateway.chragesollevaAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?), ma gli argomenti non vengono controllati.create_autospec(PaymentGateway, instance=True), oppureautospec=Truesupatch— nomi reali e firme reali.
Perché questo conta oltre gli errori di battitura? Perché il client reale cambia. Quando un aggiornamento della libreria rinomina un metodo o aggiunge un argomento obbligatorio, un mock autospec fallisce lo stesso giorno; un Mock così com'è continua ad approvare codice che non funziona più. Per qualsiasi cosa che sostituisce una classe reale, usa il livello più rigido.
Python protegge da solo un tipo di errore di battitura: dal 3.12, un'asserzione scritta male come assert_called_once_wiht solleva AttributeError: 'assert_called_once_wiht' is not a valid assertion. invece di passare silenziosamente. Quella protezione conosce solo i nomi delle asserzioni del mock. Non può sapere che il tuo gateway ha charge e non chrage — solo una spec può farlo.
patch — sostituire un nome che il codice cerca da solo
place_order riceve il proprio gateway come argomento, quindi passare un mock è facile. Spesso il codice recupera da solo la propria dipendenza:
# 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 sostituisce un nome con un MagicMock per la durata di un test, poi rimette l'originale al suo posto. Funziona come decoratore — il mock arriva come argomento — o come 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.08sLa destinazione è "signup.send_email", non "emailer.send_email". Questa è la regola del capitolo precedente: applica la patch dove il nome viene cercato, non dove è definito. from emailer import send_email ha copiato il nome in signup, quindi register lo cerca lì. Il modo sbagliato — applicare la patch al modulo originale — lascia la copia intatta e la funzione reale viene eseguita:
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.08sL'errore proviene da emailer.py — quello reale. Qui il sostituto solleva l'errore in modo evidente; un vero client di email avrebbe semplicemente inviato l'email.
patch accetta anche autospec=True, per lo stesso motivo di prima: patch("signup.send_email", autospec=True) crea un mock che accetta solo (to, subject).
mocker — la fixture di pytest-mock
Il plugin pytest-mock racchiude tutto questo in una fixture chiamata mocker. mocker.patch accetta gli stessi argomenti di patch, non richiede decoratore o with, e viene annullato automaticamente quando il test finisce — proprio come monkeypatch. mocker.spy è l'eccezione: non sostituisce nulla. Il metodo reale viene eseguito; lo spy osserva soltanto.
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.08sLo spy ha registrato la chiamata come farebbe un mock, ha conservato il risultato reale in spy_return, e il vero charge ha comunque fatto il suo lavoro — gateway.charges è stato riempito. (FakeGateway è definito nella sezione successiva.) mocker offre anche mocker.Mock, mocker.MagicMock e mocker.create_autospec, così un test può ottenere tutto da una sola fixture.
Stub, mock, fake — e quando non fare mock
Tre parole che si confondono, e la differenza determina cosa il tuo test può cogliere:
- Uno stub si limita a rispondere.
gateway.charge.return_value = {"id": "ch_1"}, e non controlli mai come è stato chiamato. Alimenta il codice sotto test. - Un mock è uno stub che interroghi dopo:
assert_called_once_with(...). Il test riguarda la chiamata in sé. - Un fake è un'implementazione reale, funzionante e piccola — un gateway in memoria che tiene una lista invece di parlare con una banca.
Un test fatto di mock controlla come il codice ha fatto il proprio lavoro. Esageri e il test smette di controllare cosa ha fatto. Prima il modo sbagliato:
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.07sPassa. Il carrello costa un centesimo, e il test "conferma" un addebito di 2500 — perché è il test stesso a aver sostituito il calcolo. Rompi cart_total e questo test passa comunque. Rinomina cart_total senza cambiare alcun comportamento e fallisce. Questo è l'odore dell'eccessivo mocking: un test che rispecchia l'implementazione riga per riga, testa i propri mock e si rompe a ogni refactor.
Il modo giusto segue il piano dall'inizio del capitolo: usa i mock al bordo del tuo sistema, e solo lì. Il fornitore di pagamenti e il server SMTP sono il bordo. cart_total è codice tuo — lascialo correre. E per collaboratori con un'interfaccia piccola, un fake di solito batte un 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))Un test con dei fake fa asserzioni sullo stato — cosa è finito nella lista — anziché su una sequenza di chiamate. Si legge come una descrizione del comportamento, e sopravvive a un refactor finché il comportamento si mantiene. Scrivi il fake una volta, in fakes.py o in una fixture di conftest.py, e ogni test lo riusa. Il suo unico debole è che un fake può divergere dal client reale — ed è per questo che l'esempio completo mantiene un test autospec per il contratto.
Un esempio completo
test_checkout.py implementa il piano di test riga per riga, con ogni strumento al posto giusto — fake per il comportamento, un mock autospec per il contratto, un side_effect per l'errore:
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 ===============================Vale la pena notare due cose.
Primo, i tre test di comportamento non contengono alcun assert_called.... Controllano l'esito — il risultato, gli addebiti registrati, le email inviate — quindi place_order potrebbe essere riscritto da zero e continuerebbero a valere.
Secondo, l'unico test che controlla una chiamata è il contratto con PaymentGateway, e usa create_autospec. Se qualcuno rinomina charge o aggiunge un argomento obbligatorio, quel test fallisce invece di approvare in silenzio. Cinque righe nel piano, cinque test, niente che testi l'interno di place_order.
Quando qualcosa va storto
AssertionError: expected call not found. Il mock è stato chiamato, ma non con questi argomenti. Il messaggio stampa Expected: e Actual: uno sopra l'altro — confrontali carattere per carattere. Un argomento posizionale contro un argomento keyword (charge(2500, "tok") contro charge(2500, token="tok")) conta come chiamata diversa.
AssertionError: Expected 'charge' to be called once. Called 2 times. Il codice lo ha chiamato più di una volta, e la riga Calls: elenca ogni chiamata. Spesso è un ciclo di retry, oppure un mock riusato tra due azioni in un test.
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? Una spec che fa il proprio lavoro: il codice usa un nome che la classe reale non ha. Correggi il codice, non il test.
TypeError: missing a required argument: 'token' Un mock autospec ha intercettato una chiamata che non corrisponde alla firma reale. Il codice fallirebbe allo stesso modo contro il client reale.
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' La destinazione di patch nomina qualcosa che non esiste — un errore di battitura, o il modulo sbagliato. patch si rifiuta di inventare nuovi attributi, ed è un bene.
La patch "non ha fatto nulla" e il servizio reale è stato chiamato Hai applicato la patch dove il nome è definito, non dove viene cercato. Se il modulo faceva from emailer import send_email, applica la patch a "signup.send_email".
StopIteration da una chiamata a un mock side_effect era una lista, e il codice ha fatto più chiamate di quanti elementi avesse la lista.
Un risultato contiene <MagicMock name='mock.charge().__getitem__()' ...> anziché dati Non hai mai impostato return_value, e un MagicMock ha risposto volentieri a receipt["id"] con un altro mock. Con un Mock semplice lo stesso codice fallisce con TypeError: 'Mock' object is not subscriptable. Imposta return_value con la forma che restituisce il client reale.
fixture 'mocker' not found pytest-mock non è installato nell'ambiente da cui pytest viene eseguito. pip install pytest-mock.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
side_effect è una lista di tre elementi, quello di mezzo è un'eccezione. Cosa viene stampato esattamente?
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
Il test fallisce con RuntimeError: real SMTP call — è la vera send ad essere eseguita. Quale modifica risolve il problema?
# 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")- AUsare `mocker.spy(mailer, "send")` al posto di `mocker.patch`
- BAggiungere `import mailer` a `orders.py` prima di chiamare `send`
- CScrivere `mocker.patch("orders.send")` — dove `orders` cerca il nome
- DScrivere `mocker.patch("mailer.send", autospec=True)`
pay dimentica il token. Quale sostituto deve usare un test per intercettare questo 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)
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Scrivi weather.py con una classe WeatherClient il cui metodo get_forecast(self, city) solleva RuntimeError — rappresenta il vero client HTTP — e una funzione umbrella_advice(city, client). La funzione chiama client.get_forecast(city), che restituisce un dizionario come {"rain_chance": 70}, e restituisce "take an umbrella" quando la probabilità è 50 o superiore, altrimenti "leave it at home". Se il client solleva TimeoutError, restituisce "forecast unavailable".
Inizia con un piano di test (caso | input | valore atteso), poi scrivi test_weather.py:
- Con
create_autospec(WeatherClient, instance=True)ereturn_value, testa entrambi i consigli — inclusa la soglia di esattamente 50 — e controlla conassert_called_once_withche la città sia stata passata. - Con
side_effect = TimeoutError(...), testa il percorso"forecast unavailable". - Con
side_effectimpostato a una lista, ottieni due risposte diverse da un solo mock. - Scrivi un
FakeWeatherClientche tiene un dizionario città → probabilità di pioggia, e testa con esso.
Poi un esperimento: fingi che la libreria sia stata aggiornata e cambia il metodo reale in get_forecast(self, city, units). Esegui un test che usa Mock() e uno che usa create_autospec. Quale se ne accorge?
Soluzione
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"Il piano:
| Caso | Input | Valore atteso | |---|---|---| | Piovoso | probabilità 70 | "take an umbrella" | | Soglia | probabilità 50 | "take an umbrella" | | Appena sotto | probabilità 49 | "leave it at home" | | Errore di rete | il client solleva TimeoutError | "forecast unavailable" | | Contratto | qualsiasi città | get_forecast chiamato una volta con quella città |
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 ===============================L'esperimento, con get_forecast(self, city, units) in WeatherClient e questo 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.07sPerché ogni decisione:
- Una fixture costruisce il mock autospec. Ogni test ne riceve uno nuovo, quindi i contatori delle chiamate non trapelano mai da un test all'altro, e la versione rigida è quella predefinita anziché una cosa da ricordare.
parametrizecopre 70, 50 e 49. La riga>=è quella più facile da sbagliare; 50 e 49 stanno da ciascun lato. Un solo caso "piovoso" passerebbe anche con>.assert_called_once_with("Dhaka")sta nel test parametrizzato, perché passare la città fa parte del contratto con il client — l'unica chiamata che attraversa il confine.- Il test del timeout controlla solo il valore di ritorno. Ciò che conta è ciò che l'utente vede; quante volte è stato interrogato il client non fa parte della promessa.
- Il fake non richiede preparazione per ogni test: descrivi il mondo (
{"Dhaka": 80, "Cairo": 5}) e gli fai domande. Per un client di tua proprietà, di solito è il test più leggibile. - L'esperimento mostra il costo di un
Mockcosì com'è: dopo l'aggiornamento il client reale fallirebbe a ogni chiamata, e il test con il mock semplice resta verde. Il test autospec fallisce lo stesso giorno, con l'argomento mancante indicato.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz