En este tema veremos cómo usar dobles de prueba para reemplazar dependencias externas durante el ciclo de TDD. El objetivo no es usar mocks en todas partes, sino aislar con criterio aquello que vuelve una prueba lenta, frágil o difícil de controlar.
Trabajaremos con un caso práctico: confirmar un pedido, guardarlo y enviar una notificación sin depender de una base de datos real ni de un servicio externo de correo.
Un doble de prueba es un reemplazo controlado de una dependencia real. Puede simular una base de datos, una API, un servicio de correo, un reloj, una cola de mensajes o cualquier recurso externo.
En este curso usaremos primero dobles simples escritos a mano. Son más explícitos y suelen ser suficientes para muchos casos de TDD.
El requisito inicial será:
El guardado y el correo son dependencias externas. La regla de aplicación puede probarse sin una base de datos ni un servidor de correo real.
Creamos un repositorio en memoria y un enviador de correos que registra los mensajes enviados.
Archivo a crear: tests/test_confirmar_pedido.py
from confirmar_pedido import ConfirmarPedido
class RepositorioPedidosEnMemoria:
def __init__(self):
self.pedidos = {}
def guardar(self, pedido):
self.pedidos[pedido["id"]] = pedido
def obtener(self, pedido_id):
return self.pedidos[pedido_id]
class EnviadorCorreoSpy:
def __init__(self):
self.correos_enviados = []
def enviar(self, destinatario, asunto):
self.correos_enviados.append({
"destinatario": destinatario,
"asunto": asunto,
})
def test_confirma_pedido_y_envia_correo():
repositorio = RepositorioPedidosEnMemoria()
correo = EnviadorCorreoSpy()
repositorio.guardar({
"id": 1,
"email": "ana@example.com",
"estado": "pendiente",
})
caso_de_uso = ConfirmarPedido(repositorio, correo)
caso_de_uso.ejecutar(1)
pedido = repositorio.obtener(1)
assert pedido["estado"] == "confirmado"
assert correo.correos_enviados == [
{
"destinatario": "ana@example.com",
"asunto": "Pedido confirmado",
}
]
Ejecutamos python -m pytest. La prueba falla porque todavía no existe el caso
de uso.
Creamos el caso de uso usando las dependencias recibidas por constructor.
Archivo a crear: src/confirmar_pedido.py
class ConfirmarPedido:
def __init__(self, repositorio, correo):
self.repositorio = repositorio
self.correo = correo
def ejecutar(self, pedido_id):
pedido = self.repositorio.obtener(pedido_id)
pedido["estado"] = "confirmado"
self.repositorio.guardar(pedido)
self.correo.enviar(
pedido["email"],
"Pedido confirmado"
)
El caso de uso no sabe si el repositorio es una base de datos real o una implementación en memoria. Ese límite facilita la prueba.
Esta prueba quiere comprobar la coordinación del caso de uso. Si usáramos una base de datos real, tendríamos que crear tablas, limpiar datos y configurar conexión.
Eso puede ser útil en pruebas de integración, pero no es necesario para cada ciclo pequeño de TDD.
Agregamos una regla de error. Si el pedido no existe, debe informarse claramente.
Archivo a modificar: tests/test_confirmar_pedido.py
import pytest
def test_no_permite_confirmar_pedido_inexistente():
repositorio = RepositorioPedidosEnMemoria()
correo = EnviadorCorreoSpy()
caso_de_uso = ConfirmarPedido(repositorio, correo)
with pytest.raises(ValueError, match="Pedido inexistente"):
caso_de_uso.ejecutar(99)
assert correo.correos_enviados == []
La prueba también verifica que no se envíe correo cuando la operación falla.
El fake debe comportarse de una forma útil para el caso de uso. Podemos hacer que devuelva
None cuando no encuentra un pedido.
Archivo a modificar: tests/test_confirmar_pedido.py
class RepositorioPedidosEnMemoria:
def __init__(self):
self.pedidos = {}
def guardar(self, pedido):
self.pedidos[pedido["id"]] = pedido
def obtener(self, pedido_id):
return self.pedidos.get(pedido_id)
El doble sigue siendo simple, pero ahora representa mejor el contrato que necesita el caso de uso.
Modificamos el caso de uso.
Archivo a modificar: src/confirmar_pedido.py
def ejecutar(self, pedido_id):
pedido = self.repositorio.obtener(pedido_id)
if pedido is None:
raise ValueError("Pedido inexistente")
pedido["estado"] = "confirmado"
self.repositorio.guardar(pedido)
self.correo.enviar(
pedido["email"],
"Pedido confirmado"
)
Ejecutamos toda la suite. La prueba exitosa y la prueba de error deben pasar.
Ahora supongamos que el caso de uso debe consultar si el cliente acepta correos comerciales. La dependencia solo necesita devolver una respuesta preparada.
Archivo a modificar: tests/test_confirmar_pedido.py
class PreferenciasClienteStub:
def __init__(self, acepta_correos=True):
self.acepta_correos = acepta_correos
def acepta_correos_transaccionales(self, email):
return self.acepta_correos
Es un stub porque su tarea principal es devolver un valor controlado por la prueba.
Definimos que si el cliente no acepta correos transaccionales, el pedido se confirma pero no se envía correo.
Archivo a modificar: tests/test_confirmar_pedido.py
def test_no_envia_correo_si_cliente_no_acepta_notificaciones():
repositorio = RepositorioPedidosEnMemoria()
correo = EnviadorCorreoSpy()
preferencias = PreferenciasClienteStub(acepta_correos=False)
repositorio.guardar({
"id": 1,
"email": "ana@example.com",
"estado": "pendiente",
})
caso_de_uso = ConfirmarPedido(repositorio, correo, preferencias)
caso_de_uso.ejecutar(1)
assert repositorio.obtener(1)["estado"] == "confirmado"
assert correo.correos_enviados == []
La prueba controla la respuesta de preferencias sin llamar a un servicio externo.
Recibimos la nueva dependencia por constructor.
Archivo a modificar: src/confirmar_pedido.py
class ConfirmarPedido:
def __init__(self, repositorio, correo, preferencias):
self.repositorio = repositorio
self.correo = correo
self.preferencias = preferencias
Luego usamos la dependencia antes de enviar el correo.
if self.preferencias.acepta_correos_transaccionales(pedido["email"]):
self.correo.enviar(
pedido["email"],
"Pedido confirmado"
)
Las pruebas anteriores deben crear también el stub de preferencias. Podemos usar una función auxiliar para no repetir tanto armado.
Archivo a modificar: tests/test_confirmar_pedido.py
def crear_caso_de_uso(acepta_correos=True):
repositorio = RepositorioPedidosEnMemoria()
correo = EnviadorCorreoSpy()
preferencias = PreferenciasClienteStub(acepta_correos)
caso_de_uso = ConfirmarPedido(repositorio, correo, preferencias)
return caso_de_uso, repositorio, correo
Esta ayuda pertenece a las pruebas. Su objetivo es reducir repetición sin ocultar el comportamiento principal.
Python incluye unittest.mock. Puede ser útil, pero si se usa en exceso las
pruebas pueden quedar atadas a detalles de interacción.
Ejemplo simple:
from unittest.mock import Mock
def test_envia_correo_al_confirmar_pedido():
repositorio = RepositorioPedidosEnMemoria()
correo = Mock()
preferencias = PreferenciasClienteStub()
repositorio.guardar({
"id": 1,
"email": "ana@example.com",
"estado": "pendiente",
})
caso_de_uso = ConfirmarPedido(repositorio, correo, preferencias)
caso_de_uso.ejecutar(1)
correo.enviar.assert_called_once_with(
"ana@example.com",
"Pedido confirmado"
)
Este mock verifica una interacción. Es razonable si enviar el correo es el efecto observable que queremos proteger.
Un mock puede volver frágil una prueba si obliga a un orden o a una cantidad de llamadas que el negocio no exige.
Ejemplo a evitar:
correo.enviar.assert_called_once()
repositorio.guardar.assert_called_once()
preferencias.acepta_correos_transaccionales.assert_called_once()
Si la prueba verifica cada paso interno, cualquier refactor del caso de uso puede romperla aunque el comportamiento final siga siendo correcto.
Si podemos comprobar el resultado final mediante estado observable, muchas veces no hace falta verificar todas las llamadas internas.
pedido = repositorio.obtener(1)
assert pedido["estado"] == "confirmado"
assert correo.correos_enviados == [
{
"destinatario": "ana@example.com",
"asunto": "Pedido confirmado",
}
]
Esta prueba se enfoca en el resultado que importa para el caso de uso.
Los dobles funcionan mejor cuando existe un límite claro. Por ejemplo, el caso de uso espera
que el repositorio tenga métodos obtener y guardar, sin importar
si detrás hay memoria, archivo o base de datos.
Si el límite está mal diseñado, los dobles se vuelven difíciles de escribir y las pruebas empiezan a revelar acoplamiento.
Construí con TDD un caso de uso RegistrarUsuario.
ValueError.python -m pytest después de cada regla.Los dobles de prueba son herramientas útiles cuando el sistema necesita colaborar con dependencias externas. Usados con moderación, permiten avanzar con TDD sin depender de red, base de datos o servicios reales. Usados en exceso, pueden volver las pruebas frágiles y demasiado acopladas al diseño interno.
En el próximo tema veremos cómo usar pruebas de aceptación pequeñas como guía del desarrollo.