Mocking — comprobar cómo se llamó a un servicio externo
Usa un Mock como sustituto de un cliente de pagos o correo y comprueba cómo fue llamado. return_value, side_effect, autospec, patch, mocker — y cuándo un fake supera a un mock.
¿Cómo quieres aprender?
El problema que vamos a resolver
Aquí tienes un pequeño proceso de pago. Calcula el total de un carrito, cobra una tarjeta a través de un cliente de pagos y le envía un correo 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}En la vida real charge cobra dinero de una tarjeta real, por lo que un test nunca debe llegar a él. En el capítulo anterior monkeypatch te permitía reemplazar algo —pero reemplazarlo es solo la mitad del trabajo aquí. El valor de retorno de place_order dice "paid", mientras que los errores que importan están en las llamadas: ¿se le cobró a la tarjeta 2500 o 25? ¿Con qué token? ¿Una vez o dos? ¿Se envió el correo a la dirección correcta? Para responder a eso, el sustituto tiene que recordar cómo fue llamado.
Podrías escribir una pequeña clase que registre eso, una por cada dependencia. unittest.mock ya la ha escrito por ti.
By the end of this chapter you can
- Usar
Mockcomo sustituto de un cliente y comprobar cómo fue llamado conassert_called_once_with,call_args,call_countymock_calls - Hacer que un mock devuelva valores o lance excepciones con
return_valueyside_effect - Explicar por qué un
Mockbásico es peligroso y cerrar la brecha conspec=ycreate_autospec - Reemplazar un nombre con
patch, como decorador y como gestor de contexto, en el lugar correcto - Usar
mocker.patchymocker.spyde pytest-mock - Distinguir entre un stub, un mock y un fake, y saber cuándo un fake es el mejor test
Requisitos previos: monkeypatch — reemplazar elementos para un solo test.
Before you write the test
Antes de que aparezca ningún Mock, escribe qué promete realmente place_order. Tiene una entrada que tú controlas (el carrito, el correo y el token) y dos efectos secundarios que no puedes ver en su valor de retorno: un cargo y un correo. Un test para código como este tiene que comprobar ambas mitades.
El contrato. Dado un carrito no vacío, place_order cobra exactamente el total, una sola vez, con el token proporcionado; envía un correo de confirmación; devuelve "paid". Si la tarjeta es rechazada, no cobra nada, envía un correo de disculpa y devuelve "declined". Un carrito vacío lanza ValueError antes de que nada salga del edificio. Un fallo de red no se silencia.
La preparación. pytest en tu entorno virtual, payments.py y checkout.py importables desde donde ejecutas pytest, y el plugin pytest-mock para la fixture mocker (pip install pytest-mock). unittest.mock viene con Python. Sin clave de API, sin red: si un test necesita cualquiera de las dos cosas, no es un test unitario.
El plan.
| Caso | Entrada | Esperado | |---|---|---| | Camino feliz (happy path) | 2 × 1000 + 1 × 500, tarjeta aceptada | un cargo de 2500 con tok_visa; correo de confirmación; "paid" | | Tarjeta rechazada | mismo carrito, gateway lanza PaymentDeclined | ningún cargo registrado; correo de disculpa; "declined" | | Carrito vacío | [] | ValueError("cart is empty"); gateway y mailer intactos | | Fallo de red | gateway lanza TimeoutError | TimeoutError se propaga; ningún correo | | Contrato | cualquier pedido | charge llamado con la firma real (amount_cents, token) |
Qué no probar. El propio proveedor de pagos: ese es el trabajo de sus autores, y de todos modos tu test no puede alcanzarlo. El sum de Python. Y los pasos internos de place_order: si llama a cart_total una vez o hace la aritmética en línea es asunto suyo. El plan enumera los resultados y la única llamada que cruza la frontera, nada más. Tenlo presente; es la diferencia entre un mock útil y uno perjudicial, y el final del capítulo volverá sobre ello.
Mock — an object that writes down every call
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 no tiene ningún método charge; no se definió nada. Un Mock inventa cualquier atributo que toques, y cada atributo es en sí mismo un Mock al que se puede llamar. Cada llamada queda registrada: called indica si ocurrió, call_count cuántas veces y call_args exactamente con qué. mock_calls en el objeto padre contiene cada llamada a cada método, en orden —útil cuando el orden importa.
Leer esos atributos en un simple assert funciona, pero los métodos de aserción lo dicen en una sola línea y se explican por sí mismos cuando fallan:
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 comprueba dos cosas: hubo exactamente una llamada y tuvo exactamente estos argumentos. Sus parientes son assert_called_once() (una vez, con cualquier argumento), assert_called_with(...) (solo la última llamada) y assert_not_called(). (Los tracebacks en este capítulo están recortados con ... cuando solo importa la última línea).
¿Por qué «una sola vez»? Porque cobrar una tarjeta dos veces es un error real, y assert_called_with por sí solo no notaría una segunda llamada.
return_value and side_effect — what the mock gives back
Por defecto, una llamada devuelve otro Mock. El código que hace receipt["id"] necesita una respuesta real, así que dale 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 misma respuesta, sean cuales sean los argumentos. Cuando eso no sea suficiente, define side_effect. Tiene tres formas.
Una excepción — gateway.charge.side_effect = PaymentDeclined("card declined") hace que cada llamada la lance. Así es como alcanzas los caminos no felices que son difíciles de provocar en la realidad: una tarjeta rechazada, un timeout, un 500 del servidor.
Una lista — un elemento por llamada, en orden. Si hay una excepción en la lista, se lanza; cualquier otra cosa se devuelve. Ideal para «falla una vez, luego tiene éxito»:
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 tercera llamada encontró la lista vacía. Un StopIteration proveniente de un mock significa que el código lo llamó más veces de las que habías previsto —lo cual en sí mismo vale la pena saber.
Una función — se la llama con los mismos argumentos que al mock, y lo que sea que devuelva es la respuesta. gateway.charge.side_effect = lambda amount_cents, token: {"status": "declined"} if amount_cents > 100_000 else {"id": "ch_1"} da una respuesta que depende de la entrada —y las llamadas se siguen registrando y contando.
MagicMock — when the code uses len, with or for
Un Mock simple no admite los métodos especiales de Python: len(Mock()) lanza TypeError: object of type 'Mock' has no len(), y with Mock(): también falla. MagicMock los admite con valores por defecto razonables: len(MagicMock()) es 0, funciona dentro de un bloque with, y m.__len__.return_value = 3 cambia la respuesta.
Recurre a MagicMock cuando el sustituto se use como gestor de contexto, contenedor o iterador —por ejemplo, una conexión a una base de datos dentro de un bloque with—. patch y mocker.patch, más abajo, crean MagicMocks por defecto.
The danger: a Mock says yes to everything
Esa naturaleza tan complaciente tiene un precio. Escribe mal un método y nadie se quejará:
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 esa errata estuviera en checkout.py, un test con un simple Mock podría pasar, y el primer cliente real recibiría un AttributeError. Los argumentos incorrectos pasan desapercibidos de la misma manera. Aquí tienes la forma incorrecta y la forma correcta frente a frente: el código bajo prueba olvida el 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.07sEl simple Mock dejó pasar una llamada rota: un test en verde que no protege nada. create_autospec construye un mock a partir del PaymentGateway real: solo tiene los atributos de esa clase, y cada método comprueba sus argumentos frente a la firma real. instance=True significa «una instancia de la clase», no la clase en sí.
Tres niveles de rigurosidad:
Mock()— cualquier atributo, cualquier argumento.Mock(spec=PaymentGateway)— solo nombres de atributos reales (gateway.chragelanzaAttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'?), pero los argumentos no se comprueban.create_autospec(PaymentGateway, instance=True), oautospec=Trueenpatch— nombres reales y firmas reales.
¿Por qué importa esto más allá de las erratas? Porque el cliente real cambia. Cuando una actualización de una biblioteca renombra un método o añade un argumento obligatorio, un mock con autospec falla el mismo día; un simple Mock sigue dándole la razón a un código que ya no funciona. Para cualquier cosa que sustituya a una clase real, utiliza el nivel más estricto.
El propio Python previene un tipo de errata: desde 3.12, una aserción mal escrita como assert_called_once_wiht lanza AttributeError: 'assert_called_once_wiht' is not a valid assertion. en lugar de pasar silenciosamente. Esa protección solo conoce los nombres de las propias aserciones del mock. No puede saber que tu gateway tiene charge y no chrage; solo un spec puede saberlo.
patch — replacing a name the code looks up itself
place_order recibe su gateway como argumento, por lo que pasarle un mock es fácil. A menudo, el código obtiene su dependencia por sí mismo:
# 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 reemplaza un nombre con un MagicMock durante lo que dura un test y luego vuelve a colocar el original. Funciona como decorador —el mock llega como argumento— o como gestor de contexto:
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.08sEl objetivo es "signup.send_email", no "emailer.send_email". Esa es la regla del capítulo anterior: parchea donde se busca el nombre, no donde se define. from emailer import send_email copió el nombre en signup, por lo que register lo busca allí. La forma incorrecta —parchear el módulo original— deja la copia intacta y la función real se ejecuta:
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.08sEl error proviene de emailer.py, el real. Aquí el sustituto lanza una excepción ruidosa; un cliente de correo real simplemente habría enviado el correo.
patch también acepta autospec=True, por la misma razón de antes: patch("signup.send_email", autospec=True) crea un mock que solo acepta (to, subject).
mocker — the pytest-mock fixture
El plugin pytest-mock envuelve todo esto en una fixture llamada mocker. mocker.patch toma los mismos argumentos que patch, no necesita decorador ni with, y se deshace automáticamente cuando el test termina —igual que monkeypatch—. mocker.spy es el caso atípico: no reemplaza nada. El método real se ejecuta; el spy solo observa.
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.08sEl spy registró la llamada como un mock, conservó el resultado real en spy_return y el charge real siguió haciendo su trabajo: gateway.charges se llenó. (FakeGateway se define en la siguiente sección). mocker también ofrece mocker.Mock, mocker.MagicMock y mocker.create_autospec, de modo que un test puede obtenerlo todo desde una sola fixture.
Stub, mock, fake — and when not to mock
Tres palabras que se confunden, y su diferencia decide qué puede detectar tu test:
- Un stub solo responde.
gateway.charge.return_value = {"id": "ch_1"}, y nunca compruebas cómo fue llamado. Alimenta al código bajo prueba. - Un mock es un stub al que interrogas después:
assert_called_once_with(...). El test se centra en la llamada misma. - Un fake es una implementación real, funcional y pequeña —un gateway en memoria que mantiene una lista en lugar de comunicarse con un banco.
Un test hecho de mocks comprueba cómo hizo el código su trabajo. Si te excedes, el test deja de comprobar qué hizo. Primero la forma incorrecta:
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.07sPasa. El carrito cuesta un centavo, y el test «confirma» un cargo de 2500 —porque el propio test reemplazó la aritmética—. Rompe cart_total y este test seguirá pasando. Renombra cart_total sin cambiar ningún comportamiento y fallará. Ese es el olor a exceso de mocks (olor a exceso de mocks): un test que refleja la implementación línea por línea, prueba sus propios mocks y se rompe con cada refactorización.
La forma correcta sigue el plan del inicio del capítulo: usa mocks en los límites de tu sistema, y únicamente allí. El proveedor de pagos y el servidor SMTP son el límite. cart_total es tu propio código: déjalo ejecutarse. Y para colaboradores con una interfaz pequeña, un fake suele superar a 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 fakes hace asserts sobre el estado —lo que terminó en la lista— en lugar de sobre una secuencia de llamadas. Se lee como una descripción del comportamiento y sobrevive a una refactorización mientras el comportamiento se mantenga. Escribes el fake una vez, en fakes.py o en una fixture de conftest.py, y cada test lo reutiliza. Su única debilidad es que un fake puede desfasarse del cliente real —motivo por el cual el ejemplo completo conserva un test con autospec para el contrato.
A complete example
test_checkout.py implementa el plan de tests fila por fila, con cada herramienta donde corresponde: fakes para el comportamiento, un mock con autospec para el contrato y un side_effect para el fallo:
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 notar dos cosas.
En primer lugar, los tres tests de comportamiento no contienen ningún assert_called.... Comprueban el desenlace —el resultado, los cargos registrados, los correos enviados—, por lo que place_order podría reescribirse desde cero y seguirían siendo válidos.
En segundo lugar, el único test que comprueba una llamada es el contrato con PaymentGateway, y utiliza create_autospec. Si alguien renombra charge o añade un argumento obligatorio, ese test falla en lugar de asentir en silencio. Cinco filas en el plan, cinco tests, nada que pruebe el interior de place_order.
When it breaks
AssertionError: expected call not found. El mock fue llamado, pero no con estos argumentos. El mensaje imprime Expected: y Actual: uno encima del otro; compáralos carácter por carácter. Un argumento posicional frente a un argumento nombrado (charge(2500, "tok") frente a charge(2500, token="tok")) cuenta como una llamada diferente.
AssertionError: Expected 'charge' to be called once. Called 2 times. El código lo llamó más de una vez, y la línea Calls: enumera cada llamada. Suele deberse a un bucle de reintento o a un mismo mock reutilizado en dos acciones dentro de un test.
AttributeError: Mock object has no attribute 'chrage'. Did you mean: 'charge'? Un spec haciendo su trabajo: el código usa un nombre que la clase real no tiene. Corrige el código, no el test.
TypeError: missing a required argument: 'token' Un mock con autospec detectó una llamada que no coincide con la firma real. El código fallaría de la misma manera frente al cliente real.
AttributeError: <module 'signup' from '...'> does not have the attribute 'send_mail' El objetivo de patch nombra algo que no existe: una errata o el módulo equivocado. patch se niega a inventar atributos nuevos, lo cual es algo bueno.
El parche «no hizo nada» y se llamó al servicio real Parcheaste donde se define el nombre, no donde se busca. Si el módulo hizo from emailer import send_email, parchea "signup.send_email".
StopIteration al llamar a un mock side_effect era una lista y el código realizó más llamadas de los elementos que tenía la lista.
Un resultado contiene <MagicMock name='mock.charge().__getitem__()' ...> en lugar de datos Nunca definiste return_value, y un MagicMock respondió alegremente a receipt["id"] con otro mock. Con un simple Mock, el mismo código falla con TypeError: 'Mock' object is not subscriptable. Define return_value con la forma que devuelve el cliente real.
fixture 'mocker' not found pytest-mock no está instalado en el entorno desde el que se ejecuta pytest. pip install pytest-mock.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
side_effect es una lista de tres elementos, y el del medio es una excepción. ¿Qué se imprime exactamente?
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
El test falla con RuntimeError: real SMTP call —se ejecutó el send real—. ¿Qué cambio lo soluciona?
# 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")- AUsar `mocker.spy(mailer, "send")` en lugar de `mocker.patch`
- BAñadir `import mailer` a `orders.py` antes de llamar a `send`
- CEscribir `mocker.patch("orders.send")` —donde `orders` busca el nombre
- DEscribir `mocker.patch("mailer.send", autospec=True)`
pay olvida el token. ¿Un test que use qué sustituto detectará este error?
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)
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Escribe weather.py con una clase WeatherClient cuyo método get_forecast(self, city) lance RuntimeError —hace las veces del cliente HTTP real— y una función umbrella_advice(city, client). La función llama a client.get_forecast(city), que devuelve un diccionario como {"rain_chance": 70}, y devuelve "take an umbrella" cuando la probabilidad es 50 o más; de lo contrario, devuelve "leave it at home". Si el cliente lanza TimeoutError, devuelve "forecast unavailable".
Comienza con un plan de tests (caso | entrada | esperado), y luego escribe test_weather.py:
- Con
create_autospec(WeatherClient, instance=True)yreturn_value, prueba ambos consejos —incluido el límite exactamente en 50— y comprueba conassert_called_once_withque se haya pasado la ciudad. - Con
side_effect = TimeoutError(...), prueba el camino de"forecast unavailable". - Con
side_effectconfigurado como una lista, obtén dos respuestas diferentes de un solo mock. - Escribe un
FakeWeatherClientque contenga un diccionario de ciudad → probabilidad de lluvia, y prueba con él.
Luego, un experimento: imagina que la biblioteca se actualizó y cambia el método real a get_forecast(self, city, units). Ejecuta un test que use Mock() y otro que use create_autospec. ¿Cuál de los dos se da cuenta?
Solución
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"El plan:
| Caso | Entrada | Esperado | |---|---|---| | Lluvioso | probabilidad 70 | "take an umbrella" | | Límite | probabilidad 50 | "take an umbrella" | | Justo por debajo | probabilidad 49 | "leave it at home" | | Fallo de red | client lanza TimeoutError | "forecast unavailable" | | Contrato | cualquier ciudad | get_forecast llamado una vez con esa ciudad |
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 ===============================El experimento, con get_forecast(self, city, units) en WeatherClient y este 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.07sEl porqué de cada decisión:
- Una fixture construye el mock con autospec. Cada test obtiene una copia nueva, por lo que el conteo de llamadas nunca se filtra de un test al siguiente, y la versión estricta es la predeterminada en lugar de algo que tengas que recordar.
parametrizecubre 70, 50 y 49. El>=es la línea con más probabilidades de estar mal; 50 y 49 se sitúan a ambos lados de ella. Un único caso «lluvioso» pasaría con>también.assert_called_once_with("Dhaka")se encuentra en el test parametrizado, porque pasar la ciudad es parte del contrato con el cliente —la única llamada que cruza la frontera.- El test de timeout comprueba únicamente el valor de retorno. Lo que importa es lo que ve el usuario; cuántas veces se consultó al cliente no forma parte de la promesa.
- El fake no necesita preparación por test: describes el mundo (
{"Dhaka": 80, "Cairo": 5}) y haces preguntas sobre él. Para un cliente que tú controlas, suele ser el test más legible. - El experimento muestra el costo de un simple
Mock: tras la actualización, el cliente real fallaría en cada llamada, mientras que el test con el mock simple sigue en verde. El test con autospec falla el mismo día, señalando el nombre del argumento faltante.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz