En el tema anterior vimos cómo verificar llamadas en mocks. Ahora veremos una cuestión igual de importante: decidir qué llamadas vale la pena verificar y cuáles son solo detalles internos.
Una prueba con mocks puede ser técnicamente correcta y, al mismo tiempo, demasiado frágil. Eso ocurre cuando la prueba describe paso a paso la implementación en vez de verificar el comportamiento esperado.
Una colaboración ocurre cuando una unidad de código delega trabajo en otro componente. Por ejemplo, un servicio de pedidos puede colaborar con un repositorio, una pasarela de pago, un notificador y un registro de auditoría.
No todas las colaboraciones tienen la misma importancia para la prueba. Algunas son parte del comportamiento observable; otras son decisiones internas de implementación.
Usaremos un servicio que confirma pedidos:
class ServicioPedidos:
def __init__(self, repositorio, pagos, email, auditoria):
self.repositorio = repositorio
self.pagos = pagos
self.email = email
self.auditoria = auditoria
def confirmar(self, pedido_id):
pedido = self.repositorio.buscar_por_id(pedido_id)
if pedido is None:
return False
pago = self.pagos.cobrar(pedido["tarjeta"], pedido["total"])
if not pago["aprobado"]:
self.auditoria.registrar("pedido_rechazado", pedido_id)
return False
pedido["estado"] = "confirmado"
self.repositorio.guardar(pedido)
self.email.enviar_confirmacion(pedido["email"], pedido_id)
self.auditoria.registrar("pedido_confirmado", pedido_id)
return True
Este método tiene varias colaboraciones. La prueba debe elegir cuáles observar.
Una prueba frágil podría verificar cada llamada en detalle:
from unittest.mock import Mock
def test_confirmar_pedido_demasiado_acoplado():
repositorio = Mock()
pagos = Mock()
email = Mock()
auditoria = Mock()
pedido = {
"id": 10,
"email": "ana@example.com",
"tarjeta": "4111111111111111",
"total": 2500,
"estado": "pendiente",
}
repositorio.buscar_por_id.return_value = pedido
pagos.cobrar.return_value = {"aprobado": True}
servicio = ServicioPedidos(repositorio, pagos, email, auditoria)
resultado = servicio.confirmar(10)
assert resultado is True
repositorio.buscar_por_id.assert_called_once_with(10)
pagos.cobrar.assert_called_once_with("4111111111111111", 2500)
repositorio.guardar.assert_called_once_with(pedido)
email.enviar_confirmacion.assert_called_once_with("ana@example.com", 10)
auditoria.registrar.assert_called_once_with("pedido_confirmado", 10)
Esta prueba puede estar bien si todas esas interacciones forman parte del contrato. Pero si algunas son detalles internos, el test será difícil de mantener.
Antes de verificar una llamada, conviene preguntarse:
Si queremos comprobar que el pedido terminó confirmado, podemos usar un fake de repositorio y verificar estado en lugar de verificar la llamada exacta a guardar:
class RepositorioPedidosFake:
def __init__(self, pedidos):
self.pedidos = {pedido["id"]: pedido.copy() for pedido in pedidos}
def buscar_por_id(self, pedido_id):
pedido = self.pedidos.get(pedido_id)
return None if pedido is None else pedido.copy()
def guardar(self, pedido):
self.pedidos[pedido["id"]] = pedido.copy()
Este fake permite comprobar el estado final sin atarse al objeto exacto pasado al método.
Una prueba menos acoplada puede verificar el resultado importante y las interacciones externas relevantes:
def test_confirmar_pedido_aprobado_confirma_y_notifica():
repositorio = RepositorioPedidosFake([
{
"id": 10,
"email": "ana@example.com",
"tarjeta": "4111111111111111",
"total": 2500,
"estado": "pendiente",
}
])
pagos = Mock()
pagos.cobrar.return_value = {"aprobado": True}
email = Mock()
auditoria = Mock()
servicio = ServicioPedidos(repositorio, pagos, email, auditoria)
resultado = servicio.confirmar(10)
assert resultado is True
assert repositorio.buscar_por_id(10)["estado"] == "confirmado"
email.enviar_confirmacion.assert_called_once_with("ana@example.com", 10)
auditoria.registrar.assert_called_once_with("pedido_confirmado", 10)
La prueba verifica estado final y efectos externos. No exige que la persistencia se haya implementado con una llamada exacta en un momento exacto, salvo que eso importe.
Las colaboraciones externas suelen merecer más atención porque producen efectos fuera del proceso de prueba:
En esos casos, verificar la llamada puede ser el comportamiento principal de la prueba.
En general, evita verificar llamadas que solo expresan cómo está escrito el algoritmo:
Para un pago rechazado, las interacciones importantes son diferentes:
def test_confirmar_pedido_rechazado_no_confirma_ni_notifica():
repositorio = RepositorioPedidosFake([
{
"id": 20,
"email": "luis@example.com",
"tarjeta": "4000000000000002",
"total": 900,
"estado": "pendiente",
}
])
pagos = Mock()
pagos.cobrar.return_value = {"aprobado": False}
email = Mock()
auditoria = Mock()
servicio = ServicioPedidos(repositorio, pagos, email, auditoria)
resultado = servicio.confirmar(20)
assert resultado is False
assert repositorio.buscar_por_id(20)["estado"] == "pendiente"
email.enviar_confirmacion.assert_not_called()
auditoria.registrar.assert_called_once_with("pedido_rechazado", 20)
La prueba verifica que no se confirma, no se envía email y sí se audita el rechazo.
Una buena prueba no necesariamente tiene muchas aserciones. Debe tener las aserciones correctas. Si una sola aserción sobre el estado final cubre la regla de negocio, puede ser suficiente.
En cambio, si el comportamiento consiste en llamar a una pasarela de pago o publicar un evento, entonces la interacción sí debe verificarse.
Si una dependencia es solo un dato, no hace falta mockearla. Por ejemplo, este pedido puede ser un diccionario o una dataclass:
pedido = {
"id": 10,
"email": "ana@example.com",
"total": 2500,
"estado": "pendiente",
}
Mockear datos simples suele hacer que la prueba sea menos clara. Reserva los mocks para colaboraciones con comportamiento.
Las pruebas con mocks son más claras cuando las dependencias tienen interfaces pequeñas. Es más fácil verificar email.enviar_confirmacion(email, pedido_id) que un objeto grande con veinte métodos mezclados.
Si un mock requiere mucha configuración, puede indicar que la dependencia hace demasiadas cosas o que la unidad bajo prueba está demasiado acoplada.
A veces importa el orden. Por ejemplo, no se debe enviar una confirmación antes de aprobar el pago. Pero no siempre necesitamos verificarlo explícitamente.
Si el orden es una regla crítica, podemos usar mock_calls o diseñar una prueba específica. Si no lo es, verificar orden agrega fragilidad sin beneficio.
Imagina que cambiamos el servicio para usar un método auxiliar interno:
def _confirmar_pedido(self, pedido):
pedido["estado"] = "confirmado"
self.repositorio.guardar(pedido)
Una prueba centrada en comportamiento no debería romperse por esta extracción. Si la prueba estaba verificando llamadas internas al detalle, probablemente sí se rompería aunque el sistema siga funcionando igual.
Analiza esta función:
def cancelar_reserva(reserva_id, repositorio, pagos, email, auditoria):
reserva = repositorio.buscar_por_id(reserva_id)
if reserva is None:
return False
reserva["estado"] = "cancelada"
repositorio.guardar(reserva)
if reserva["pagada"]:
pagos.reembolsar(reserva["pago_id"])
email.enviar_cancelacion(reserva["email"], reserva_id)
auditoria.registrar("reserva_cancelada", reserva_id)
return True
Escribe una prueba para una reserva pagada. Decide qué verificar como estado final y qué verificar como interacción.
Una solución equilibrada:
from unittest.mock import Mock
from reservas import cancelar_reserva
class RepositorioReservasFake:
def __init__(self, reservas):
self.reservas = {reserva["id"]: reserva.copy() for reserva in reservas}
def buscar_por_id(self, reserva_id):
reserva = self.reservas.get(reserva_id)
return None if reserva is None else reserva.copy()
def guardar(self, reserva):
self.reservas[reserva["id"]] = reserva.copy()
def test_cancelar_reserva_pagada_cancela_reembolsa_y_notifica():
repositorio = RepositorioReservasFake([
{
"id": 7,
"email": "ana@example.com",
"estado": "confirmada",
"pagada": True,
"pago_id": "PAGO-10",
}
])
pagos = Mock()
email = Mock()
auditoria = Mock()
resultado = cancelar_reserva(7, repositorio, pagos, email, auditoria)
assert resultado is True
assert repositorio.buscar_por_id(7)["estado"] == "cancelada"
pagos.reembolsar.assert_called_once_with("PAGO-10")
email.enviar_cancelacion.assert_called_once_with("ana@example.com", 7)
auditoria.registrar.assert_called_once_with("reserva_cancelada", 7)
El estado de la reserva se verifica con el fake. Las interacciones externas relevantes se verifican con mocks.
Verificar colaboraciones es útil cuando esas colaboraciones forman parte del comportamiento esperado. El problema aparece cuando la prueba se ata a pasos internos que podrían cambiar sin afectar el resultado.
En el próximo tema comenzaremos a usar patch para reemplazar funciones, clases y objetos durante una prueba.