Mocking — vérifier comment un service externe a été appelé
Utiliser un Mock à la place d'un client de paiement ou d'e-mail et vérifier comment il a été appelé. return_value, side_effect, autospec, patch, mocker — et pourquoi un fake surpasse souvent un mock.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici un petit processus de commande. Il calcule le montant d'un panier, débite une carte bancaire via un client de paiement et envoie un e-mail au client.
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}Dans la vraie vie, charge prélève de l'argent sur une vraie carte bancaire, donc un test ne doit jamais y accéder. Dans le chapitre précédent, monkeypatch vous permettait de remplacer un élément — mais le remplacer ne représente ici que la moitié du travail. La valeur de retour de place_order indique "paid", alors que les bugs qui comptent se situent dans les appels : la carte a-t-elle été débitée de 2500 ou de 25 ? Avec quel token ? Une fois, ou deux ? L'e-mail est-il parti à la bonne adresse ? Pour répondre à ces questions, le substitut doit se souvenir de la manière dont il a été appelé.
Vous pourriez écrire une petite classe d'enregistrement pour cela, une par dépendance. unittest.mock l'a déjà écrite pour vous.
À la fin de ce chapitre, vous saurez
- Utiliser
Mockcomme substitut d'un client et vérifier comment il a été appelé avecassert_called_once_with,call_args,call_countetmock_calls - Faire en sorte qu'un mock renvoie des valeurs ou lève des exceptions avec
return_valueetside_effect - Expliquer pourquoi un simple
Mocksans configuration est dangereux, et combler cette faille avecspec=etcreate_autospec - Remplacer un nom au bon endroit avec
patch, sous forme de décorateur ou de gestionnaire de contexte - Utiliser
mocker.patchetmocker.spyde pytest-mock - Distinguer un stub, un mock et un fake, et savoir quand un fake constitue le meilleur choix de test
Prérequis : monkeypatch — remplacer des éléments pour un seul test.
Avant d'écrire le test
Avant même qu'un Mock n'apparaisse, posez par écrit ce que place_order promet réellement. Elle possède une entrée que vous contrôlez (le panier, l'e-mail et le token) et deux effets de bord invisibles dans sa valeur de retour — un débit bancaire et un e-mail. Un test pour un tel code doit vérifier ces deux volets.
Le contrat. Pour un panier non vide, place_order débite exactement le total, une seule fois, avec le token fourni ; envoie un e-mail de confirmation ; renvoie "paid". Si la carte est refusée, elle n'enregistre aucun débit, envoie un e-mail d'excuses et renvoie "declined". Un panier vide lève ValueError avant que quoi que ce soit ne sorte du système. Une panne réseau n'est pas interceptée.
La configuration. pytest dans votre environnement virtuel, payments.py et checkout.py importables depuis l'emplacement où vous exécutez pytest, et le plugin pytest-mock pour la fixture mocker (pip install pytest-mock). unittest.mock est inclus dans Python. Pas de clé d'API, pas de réseau — si un test a besoin de l'un ou l'autre, ce n'est pas un test unitaire.
Le plan.
| Cas | Entrée | Résultat attendu | |---|---|---| | Cas nominal | 2 × 1000 + 1 × 500, carte acceptée | un débit de 2500 avec tok_visa ; e-mail de confirmation ; "paid" | | Carte refusée | même panier, la passerelle lève PaymentDeclined | aucun débit enregistré ; e-mail d'excuses ; "declined" | | Panier vide | [] | ValueError("cart is empty") ; passerelle et service d'e-mail intacts | | Panne réseau | la passerelle lève TimeoutError | TimeoutError se propage ; aucun e-mail | | Contrat | n'importe quelle commande | charge appelée avec la vraie signature (amount_cents, token) |
Ce qu'il ne faut pas tester. Le fournisseur de paiement lui-même — c'est le travail de ses auteurs, et votre test ne peut pas y accéder de toute façon. La fonction sum de Python. Et les étapes internes de place_order : qu'elle appelle cart_total une fois ou qu'elle intègre directement le calcul est son affaire. Le plan énumère les résultats et l'unique appel qui franchit la frontière du système — rien d'autre. Gardez cela à l'esprit ; c'est ce qui fait la différence entre un mock utile et un mock nuisible, et la fin du chapitre y reviendra.
Mock — un objet qui enregistre chaque appel
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 n'a pas de méthode charge ; rien n'a été défini. Un Mock invente n'importe quel attribut auquel vous accédez, et chaque attribut est lui-même un Mock qui peut être appelé. Chaque appel est enregistré : called indique s'il a eu lieu, call_count combien de fois, call_args avec exactement quels arguments. mock_calls sur l'objet parent contient chaque appel à chaque méthode, dans l'ordre — ce qui est utile lorsque l'ordre compte.
Lire ces attributs dans un simple assert fonctionne, mais les méthodes d'assertion l'expriment en une seule ligne et fournissent des explications claires en cas d'échec :
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 vérifie deux choses : il y a eu exactement un appel, et il comportait exactement ces arguments. Ses variantes sont assert_called_once() (une seule fois, quels que soient les arguments), assert_called_with(...) (uniquement le dernier appel) et assert_not_called(). (Les tracebacks de ce chapitre sont raccourcis avec ... lorsque seule la dernière ligne importe.)
Pourquoi « une seule fois » ? Parce que débiter une carte bancaire deux fois est un vrai bug, et assert_called_with à lui seul ne remarquerait pas un second appel.
return_value et side_effect — ce que le mock renvoie
Par défaut, un appel renvoie un autre Mock. Un code qui effectue receipt["id"] a besoin d'une vraie réponse, donnez-lui-en donc une :
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 même réponse, quels que soient les arguments. Quand cela ne suffit pas, définissez side_effect. Il existe trois formes.
Une exception — gateway.charge.side_effect = PaymentDeclined("card declined") fait en sorte que chaque appel la lève. C'est ainsi que vous testez les scénarios d'erreur difficiles à reproduire en conditions réelles : une carte refusée, un délai d'attente dépassé (timeout), une erreur 500 du serveur.
Une liste — un élément par appel, dans l'ordre. Une exception présente dans la liste est levée ; tout autre élément est renvoyé. C'est idéal pour « échoue une fois, puis réussit » :
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):
...
StopIterationUn troisième appel a trouvé la liste vide. Une exception StopIteration émise par un mock signifie que le code l'a appelé plus de fois que vous ne l'aviez prévu — ce qui est en soi une information précieuse.
Une fonction — appelée avec les mêmes arguments que le mock, et ce qu'elle renvoie constitue la réponse. gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"} fournit une réponse qui dépend des entrées — et les appels sont toujours enregistrés et comptabilisés.
MagicMock — quand le code utilise len, with ou for
Un simple Mock ne prend pas en charge les méthodes spéciales de Python : len(Mock()) lève TypeError: object of type 'Mock' has no len(), et with Mock(): échoue également. MagicMock les prend en charge avec des valeurs par défaut raisonnables — len(MagicMock()) vaut 0, il fonctionne dans un bloc with, et m.__len__.return_value = 3 modifie la réponse.
Tournez-vous vers MagicMock lorsque le substitut est utilisé comme gestionnaire de contexte, conteneur ou itérateur — par exemple une connexion à une base de données dans un bloc with. patch et mocker.patch, présentés ci-dessous, créent des MagicMock par défaut.
Le danger : un Mock dit oui à tout
Cette nature trop arrangeante a un prix. Faites une faute de frappe dans le nom d'une méthode, et rien ne s'en plaindra :
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')]Si cette coquille se trouvait dans checkout.py, un test avec un simple Mock pourrait réussir, et le premier vrai client obtiendrait une AttributeError. Des arguments incorrects passent inaperçus de la même manière. Voici la mauvaise et la bonne méthode côte à côte — le code testé oublie le 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.07sLe simple Mock a laissé passer un appel cassé — un test au vert qui ne protège rien. create_autospec construit un mock à partir de la vraie classe PaymentGateway : il ne possède que les attributs de cette classe, et chaque méthode valide ses arguments par rapport à la vraie signature. instance=True signifie « une instance de la classe », et non la classe elle-même.
Trois niveaux de rigueur :
Mock()— n'importe quel attribut, n'importe quels arguments.Mock(spec=PaymentGateway)— uniquement les vrais noms d'attributs (gateway.chragelèveAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?), mais les arguments ne sont pas vérifiés.create_autospec(PaymentGateway, instance=True), ouautospec=Truesurpatch— les vrais noms et les vraies signatures.
Pourquoi cela a-t-il de l'importance au-delà des simples fautes de frappe ? Parce que le vrai client évolue. Quand la mise à jour d'une bibliothèque renomme une méthode ou ajoute un argument obligatoire, un mock configuré avec autospec échoue le jour même ; un simple Mock continue d'approuver un code qui ne fonctionne plus. Pour tout ce qui remplace une vraie classe, utilisez le niveau le plus strict.
Python se protège lui-même contre un type d'erreur de frappe : depuis la version 3.12, une assertion mal orthographiée comme assert_called_once_wiht lève AttributeError: 'assert_called_once_wiht' is not a valid assertion. au lieu de réussir silencieusement. Cette protection ne connaît toutefois que les noms des méthodes d'assertion du mock. Elle ne peut pas deviner que votre passerelle possède charge et non chrage — seule une spécification (spec) le peut.
patch — remplacer un nom que le code recherche lui-même
place_order reçoit sa passerelle en argument, y passer un mock est donc simple. Souvent, le code va chercher sa dépendance lui-même :
# 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 remplace un nom par un MagicMock pendant la durée d'un test, puis rétablit l'original. Il fonctionne comme décorateur — le mock arrive sous forme d'argument — ou comme gestionnaire de contexte :
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 cible est "signup.send_email", et non "emailer.send_email". C'est la règle du chapitre précédent : patchez là où le nom est recherché, pas là où il est défini. L'instruction from emailer import send_email a copié le nom dans signup, donc register regarde là-bas. La mauvaise méthode — patcher le module d'origine — laisse la copie intacte et la vraie fonction s'exécute :
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'erreur provient de emailer.py — le vrai fichier. Ici, le substitut lève bruyamment une exception ; un vrai client de messagerie se serait contenté d'envoyer l'e-mail.
patch accepte également autospec=True, pour la même raison que précédemment : patch("signup.send_email", autospec=True) produit un mock qui n'accepte que (to, subject).
mocker — la fixture de pytest-mock
Le plugin pytest-mock encapsule tout cela dans une fixture nommée mocker. mocker.patch prend les mêmes arguments que patch, ne nécessite aucun décorateur ni bloc with, et s'annule automatiquement à la fin du test — tout comme monkeypatch. mocker.spy est le cas à part : il ne remplace rien. La vraie méthode s'exécute ; le spy se contente d'observer.
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.08sLe spy a enregistré l'appel comme un mock, a conservé le vrai résultat dans spy_return, et la vraie méthode charge a tout de même fait son travail — gateway.charges a été remplie. (FakeGateway est définie dans la section suivante.) mocker propose également mocker.Mock, mocker.MagicMock et mocker.create_autospec, ce qui permet à un test de tout obtenir à partir d'une seule fixture.
Stub, mock, fake — et quand ne pas mocker
Trois termes sont souvent confondus, et leur différence détermine ce que votre test est capable de détecter :
- Un stub ne fait que répondre.
gateway.charge.return_value = {"id": "ch_1"}, et vous ne vérifiez jamais comment il a été appelé. Il alimente le code testé. - Un mock est un stub que vous interrogez a posteriori :
assert_called_once_with(...). Le test porte sur l'appel lui-même. - Un fake est une petite implémentation réelle et fonctionnelle — une passerelle en mémoire qui conserve une liste au lieu de communiquer avec une banque.
Un test composé de mocks vérifie comment le code a fait son travail. Si vous en abusez, le test cesse de vérifier ce qu'il a fait. Voyons d'abord la mauvaise méthode :
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.07sIl réussit. Le panier coûte un centime, et le test « confirme » un débit de 2500 — parce que le test a lui-même remplacé le calcul. Cassez cart_total et ce test continue de réussir. Renommez cart_total sans modifier aucun comportement et il échoue. C'est l'odeur caractéristique de l'excès de mocking (over-mocking) : un test qui calque l'implémentation ligne par ligne, teste ses propres mocks et casse à chaque refactorisation.
La bonne approche suit le plan établi au début du chapitre : n'utilisez des mocks qu'aux frontières de votre système, et nulle part ailleurs. Le prestataire de paiement et le serveur SMTP constituent cette frontière. cart_total est votre propre code — laissez-le s'exécuter. Et pour les collaborateurs dotés d'une interface réduite, un fake surpasse généralement 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 avec des fakes effectue ses assertions sur l'état — ce qui s'est retrouvé dans la liste — plutôt que sur une séquence d'appels. Il se lit comme une description du comportement et survit à une refactorisation tant que ce comportement est préservé. Vous écrivez le fake une seule fois, dans fakes.py ou dans une fixture de conftest.py, et chaque test le réutilise. Sa seule faiblesse est qu'un fake peut dériver par rapport au vrai client — c'est pourquoi l'exemple complet conserve un test autospec pour le contrat.
Un exemple complet
test_checkout.py implémente le plan de test ligne par ligne, avec chaque outil à sa place — des fakes pour le comportement, un mock autospec pour le contrat, un side_effect pour la défaillance :
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 ===============================Deux points méritent d'être soulignés.
Tout d'abord, les trois tests de comportement ne contiennent aucun assert_called.... Ils vérifient le résultat final — la valeur de retour, les débits enregistrés, les e-mails envoyés — de sorte que place_order pourrait être réécrite de zéro sans qu'ils ne cessent d'être valides.
Ensuite, l'unique test qui vérifie un appel est celui du contrat avec PaymentGateway, et il utilise create_autospec. Si quelqu'un renomme charge ou ajoute un argument obligatoire, ce test échoue au lieu de valider silencieusement. Cinq lignes dans le plan, cinq tests, et rien qui ne teste les mécanismes internes de place_order.
En cas de problème
AssertionError: expected call not found. Le mock a bien été appelé, mais pas avec ces arguments. Le message affiche Expected: et Actual: l'un au-dessus de l'autre — comparez-les caractère par caractère. Un argument positionnel opposé à un argument par mot-clé (charge(2500, "tok") par rapport à charge(2500, token="tok")) compte comme un appel différent.
AssertionError: Expected 'charge' to be called once. Called 2 times. Le code l'a appelé plus d'une fois, et la ligne Calls: énumère chaque appel. Il s'agit souvent d'une boucle de réessai, ou d'un même mock réutilisé pour deux actions dans un même test.
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? Une spécification (spec) qui joue son rôle : le code utilise un nom que la vraie classe ne possède pas. Corrigez le code, pas le test.
TypeError: missing a required argument: 'token' Un mock créé avec autospec a intercepté un appel qui ne correspond pas à la vraie signature. Le code échouerait de la même façon avec le vrai client.
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' La cible de patch désigne un élément inexistant — une faute de frappe ou un mauvais module. patch refuse d'inventer de nouveaux attributs, ce qui est une bonne chose.
Le patch « n'a rien fait » et le vrai service a été appelé Vous avez patché là où le nom est défini, et non là où il est recherché. Si le module a fait from emailer import send_email, patchez "signup.send_email".
StopIteration émis par un appel de mock side_effect était une liste, et le code a effectué plus d'appels que la liste ne contenait d'éléments.
Un résultat contient <MagicMock name='mock.charge().__getitem__()' ...> au lieu de données Vous n'avez jamais défini return_value, et un MagicMock a joyeusement répondu à receipt["id"] par un autre mock. Avec un simple Mock, le même code échoue avec TypeError: 'Mock' object is not subscriptable. Définissez return_value selon la structure renvoyée par le vrai client.
fixture 'mocker' not found pytest-mock n'est pas installé dans l'environnement depuis lequel pytest est exécuté. pip install pytest-mock.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
side_effect est une liste de trois éléments, dont celui du milieu est une exception. Qu'est-ce qui est affiché exactement ?
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
Le test échoue avec RuntimeError: real SMTP call — la vraie fonction send a été exécutée. Quel changement corrige le problème ?
# 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")- AUtiliser `mocker.spy(mailer, "send")` au lieu de `mocker.patch`
- BAjouter `import mailer` dans `orders.py` avant d'appeler `send`
- CÉcrire `mocker.patch("orders.send")` — là où `orders` recherche le nom
- DÉcrire `mocker.patch("mailer.send", autospec=True)`
pay oublie le token. Quel substitut utilisé dans un test permettra de détecter ce 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)
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Écrivez weather.py avec une classe WeatherClient dont la méthode get_forecast(self, city) lève RuntimeError — elle représente le vrai client HTTP — et une fonction umbrella_advice(city, client). La fonction appelle client.get_forecast(city), qui renvoie un dictionnaire comme {"rain_chance": 70}, et renvoie "take an umbrella" lorsque la probabilité est de 50 ou plus, sinon "leave it at home". Si le client lève TimeoutError, elle renvoie "forecast unavailable".
Commencez par un plan de test (cas | entrée | résultat attendu), puis écrivez test_weather.py :
- Avec
create_autospec(WeatherClient, instance=True)etreturn_value, testez les deux conseils — y compris la valeur limite à exactement 50 — et vérifiez avecassert_called_once_withque la ville a bien été transmise. - Avec
side_effect = TimeoutError(...), testez le cas"forecast unavailable". - Avec
side_effectsous forme de liste, obtenez deux réponses différentes à partir d'un seul mock. - Écrivez un
FakeWeatherClientqui conserve un dictionnaire de ville → probabilité de pluie, et testez avec celui-ci.
Puis, réalisez une expérience : imaginez que la bibliothèque a été mise à jour et modifiez la vraie méthode en get_forecast(self, city, units). Exécutez un test qui utilise Mock() et un test qui utilise create_autospec. Lequel s'en aperçoit ?
Solution
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"Le plan :
| Cas | Entrée | Résultat attendu | |---|---|---| | Pluvieux | probabilité 70 | "take an umbrella" | | Limite | probabilité 50 | "take an umbrella" | | Juste en dessous | probabilité 49 | "leave it at home" | | Panne réseau | le client lève TimeoutError | "forecast unavailable" | | Contrat | n'importe quelle ville | get_forecast appelée une fois avec cette ville |
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'expérience, avec get_forecast(self, city, units) dans WeatherClient et ce fichier 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.07sLes raisons de chaque décision :
- Une fixture construit le mock autospec. Chaque test en reçoit un exemplaire neuf, de sorte que le nombre d'appels ne fuite jamais d'un test à l'autre, et la version stricte devient le choix par défaut plutôt qu'une consigne à ne pas oublier.
parametrizecouvre 70, 50 et 49. L'opérateur>=est l'élément le plus susceptible d'être erroné ; 50 et 49 se situent de part et d'autre de cette limite. Un simple cas « pluvieux » réussirait également avec>.assert_called_once_with("Dhaka")est placé dans le test paramétré, car transmettre la ville fait partie intégrante du contrat avec le client — c'est l'unique appel qui franchit la frontière.- Le test de timeout ne vérifie que la valeur de retour. Ce qui compte, c'est ce que voit l'utilisateur ; le nombre de fois où le client a été sollicité ne fait pas partie de la promesse.
- Le fake ne requiert aucune configuration par test : vous décrivez le contexte (
{"Dhaka": 80, "Cairo": 5}) et vous l'interrogez. Pour un client dont vous maîtrisez le code, c'est généralement le test le plus lisible. - L'expérience met en évidence le danger d'un simple
Mock: après la mise à jour, le vrai client échouerait à chaque appel, tandis que le test avec un mock ordinaire resterait au vert. Le test avec autospec échoue le jour même, en indiquant précisément l'argument manquant.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz