Muchas funciones no trabajan solas: consultan una base de datos, envían correos, llaman a una API, leen archivos o usan servicios externos. En una prueba unitaria no siempre queremos ejecutar esas dependencias reales.
En este tema veremos dobles de prueba: objetos simples que reemplazan dependencias reales durante una prueba.
Los nombres pueden variar según el autor, pero estos tres aparecen con mucha frecuencia:
Antes de usar herramientas automáticas, conviene entender cómo construir estos dobles manualmente.
Crea un proyecto nuevo:
mkdir pytest-dobles-demo
cd pytest-dobles-demo
Si pytest no está instalado en el entorno activo:
python -m pip install pytest
Crea un archivo llamado pedidos.py:
class ServicioPedidos:
def __init__(self, repositorio, notificador):
self.repositorio = repositorio
self.notificador = notificador
def crear_pedido(self, cliente, productos):
if not productos:
raise ValueError("El pedido debe tener productos")
total = sum(producto["precio"] for producto in productos)
pedido = {
"cliente": cliente,
"productos": productos,
"total": total,
"estado": "creado",
}
pedido_guardado = self.repositorio.guardar(pedido)
self.notificador.enviar(
cliente,
f"Pedido {pedido_guardado['id']} creado",
)
return pedido_guardado
def buscar_pedido(self, pedido_id):
pedido = self.repositorio.obtener_por_id(pedido_id)
if pedido is None:
return None
return pedido
def cancelar_pedido(self, pedido_id):
pedido = self.repositorio.obtener_por_id(pedido_id)
if pedido is None:
raise ValueError("Pedido inexistente")
pedido["estado"] = "cancelado"
self.repositorio.guardar(pedido)
self.notificador.enviar(
pedido["cliente"],
f"Pedido {pedido['id']} cancelado",
)
return pedido
El servicio no sabe si el repositorio guarda en memoria, en una base de datos o en una API. Solo usa los métodos que necesita.
El constructor recibe repositorio y notificador. Esa decisión facilita las pruebas, porque podemos pasar dobles en lugar de objetos reales.
servicio = ServicioPedidos(repositorio_doble, notificador_doble)
Esta técnica se conoce como inyección de dependencias. No requiere frameworks: muchas veces alcanza con pasar objetos por parámetro.
Un stub sirve cuando solo necesitamos que una dependencia devuelva un dato conocido:
class RepositorioStub:
def obtener_por_id(self, pedido_id):
return {
"id": pedido_id,
"cliente": "Ana",
"productos": [],
"total": 0,
"estado": "creado",
}
class NotificadorStub:
def enviar(self, destinatario, mensaje):
pass
La prueba se concentra en el resultado de buscar_pedido:
from pedidos import ServicioPedidos
def test_buscar_pedido_devuelve_pedido():
servicio = ServicioPedidos(RepositorioStub(), NotificadorStub())
pedido = servicio.buscar_pedido(10)
assert pedido["id"] == 10
assert pedido["cliente"] == "Ana"
También podemos preparar un stub que devuelva None:
class RepositorioVacioStub:
def obtener_por_id(self, pedido_id):
return None
def test_buscar_pedido_inexistente_devuelve_none():
servicio = ServicioPedidos(RepositorioVacioStub(), NotificadorStub())
assert servicio.buscar_pedido(99) is None
Este doble permite probar un caso borde sin configurar una base de datos vacía.
Un fake tiene comportamiento real, pero más simple que la dependencia de producción. Este repositorio guarda pedidos en una lista:
class RepositorioFake:
def __init__(self):
self.pedidos = []
self.siguiente_id = 1
def guardar(self, pedido):
pedido_guardado = pedido.copy()
if "id" not in pedido_guardado:
pedido_guardado["id"] = self.siguiente_id
self.siguiente_id += 1
self.pedidos = [
existente
for existente in self.pedidos
if existente["id"] != pedido_guardado["id"]
]
self.pedidos.append(pedido_guardado)
return pedido_guardado
def obtener_por_id(self, pedido_id):
for pedido in self.pedidos:
if pedido["id"] == pedido_id:
return pedido.copy()
return None
Este fake permite probar creación, búsqueda y cancelación sin una base de datos real.
Un mock registra cómo fue usado. Este notificador guarda los mensajes enviados:
class NotificadorMock:
def __init__(self):
self.mensajes = []
def enviar(self, destinatario, mensaje):
self.mensajes.append({
"destinatario": destinatario,
"mensaje": mensaje,
})
Con este doble podemos verificar que el servicio intentó notificar al cliente.
Combinamos un fake para guardar datos y un mock para verificar el envío:
def test_crear_pedido_guarda_y_notifica():
repositorio = RepositorioFake()
notificador = NotificadorMock()
servicio = ServicioPedidos(repositorio, notificador)
pedido = servicio.crear_pedido(
"Ana",
[
{"nombre": "Teclado", "precio": 50000},
{"nombre": "Mouse", "precio": 20000},
],
)
assert pedido["id"] == 1
assert pedido["total"] == 70000
assert pedido["estado"] == "creado"
assert notificador.mensajes == [
{
"destinatario": "Ana",
"mensaje": "Pedido 1 creado",
}
]
La prueba verifica estado devuelto y comunicación con la dependencia.
Si el pedido no tiene productos, el servicio debe lanzar error y no debería notificar:
import pytest
def test_crear_pedido_sin_productos_lanza_error_y_no_notifica():
repositorio = RepositorioFake()
notificador = NotificadorMock()
servicio = ServicioPedidos(repositorio, notificador)
with pytest.raises(ValueError):
servicio.crear_pedido("Ana", [])
assert repositorio.pedidos == []
assert notificador.mensajes == []
Esta prueba revisa que el error ocurra antes de guardar o enviar mensajes.
El fake permite preparar un pedido previo y luego cancelarlo:
def test_cancelar_pedido_cambia_estado_y_notifica():
repositorio = RepositorioFake()
notificador = NotificadorMock()
servicio = ServicioPedidos(repositorio, notificador)
pedido = repositorio.guardar({
"cliente": "Luis",
"productos": [{"nombre": "Monitor", "precio": 120000}],
"total": 120000,
"estado": "creado",
})
cancelado = servicio.cancelar_pedido(pedido["id"])
assert cancelado["estado"] == "cancelado"
assert repositorio.obtener_por_id(pedido["id"])["estado"] == "cancelado"
assert notificador.mensajes == [
{
"destinatario": "Luis",
"mensaje": "Pedido 1 cancelado",
}
]
Un doble de prueba debe ser más simple que la dependencia real. Si el fake empieza a tener demasiadas reglas, puede transformarse en otro sistema que también necesitaría pruebas.
unittest.mock, que veremos en el próximo tema.
El archivo test_pedidos.py puede quedar así:
import pytest
from pedidos import ServicioPedidos
class RepositorioStub:
def obtener_por_id(self, pedido_id):
return {
"id": pedido_id,
"cliente": "Ana",
"productos": [],
"total": 0,
"estado": "creado",
}
class RepositorioVacioStub:
def obtener_por_id(self, pedido_id):
return None
class RepositorioFake:
def __init__(self):
self.pedidos = []
self.siguiente_id = 1
def guardar(self, pedido):
pedido_guardado = pedido.copy()
if "id" not in pedido_guardado:
pedido_guardado["id"] = self.siguiente_id
self.siguiente_id += 1
self.pedidos = [
existente
for existente in self.pedidos
if existente["id"] != pedido_guardado["id"]
]
self.pedidos.append(pedido_guardado)
return pedido_guardado
def obtener_por_id(self, pedido_id):
for pedido in self.pedidos:
if pedido["id"] == pedido_id:
return pedido.copy()
return None
class NotificadorStub:
def enviar(self, destinatario, mensaje):
pass
class NotificadorMock:
def __init__(self):
self.mensajes = []
def enviar(self, destinatario, mensaje):
self.mensajes.append({
"destinatario": destinatario,
"mensaje": mensaje,
})
def test_buscar_pedido_devuelve_pedido():
servicio = ServicioPedidos(RepositorioStub(), NotificadorStub())
pedido = servicio.buscar_pedido(10)
assert pedido["id"] == 10
assert pedido["cliente"] == "Ana"
def test_buscar_pedido_inexistente_devuelve_none():
servicio = ServicioPedidos(RepositorioVacioStub(), NotificadorStub())
assert servicio.buscar_pedido(99) is None
def test_crear_pedido_guarda_y_notifica():
repositorio = RepositorioFake()
notificador = NotificadorMock()
servicio = ServicioPedidos(repositorio, notificador)
pedido = servicio.crear_pedido(
"Ana",
[
{"nombre": "Teclado", "precio": 50000},
{"nombre": "Mouse", "precio": 20000},
],
)
assert pedido["id"] == 1
assert pedido["total"] == 70000
assert pedido["estado"] == "creado"
assert notificador.mensajes == [
{
"destinatario": "Ana",
"mensaje": "Pedido 1 creado",
}
]
def test_crear_pedido_sin_productos_lanza_error_y_no_notifica():
repositorio = RepositorioFake()
notificador = NotificadorMock()
servicio = ServicioPedidos(repositorio, notificador)
with pytest.raises(ValueError):
servicio.crear_pedido("Ana", [])
assert repositorio.pedidos == []
assert notificador.mensajes == []
def test_cancelar_pedido_cambia_estado_y_notifica():
repositorio = RepositorioFake()
notificador = NotificadorMock()
servicio = ServicioPedidos(repositorio, notificador)
pedido = repositorio.guardar({
"cliente": "Luis",
"productos": [{"nombre": "Monitor", "precio": 120000}],
"total": 120000,
"estado": "creado",
})
cancelado = servicio.cancelar_pedido(pedido["id"])
assert cancelado["estado"] == "cancelado"
assert repositorio.obtener_por_id(pedido["id"])["estado"] == "cancelado"
assert notificador.mensajes == [
{
"destinatario": "Luis",
"mensaje": "Pedido 1 cancelado",
}
]
Desde la raíz del proyecto, ejecuta:
python -m pytest
La salida esperada será similar a:
collected 5 items
test_pedidos.py ..... [100%]
5 passed in 0.04s
No elijas el doble por el nombre. Elige según la necesidad concreta de la prueba.
mkdir pytest-dobles-demo
cd pytest-dobles-demo
python -m pip install pytest
python -m pytest
python -m pytest -v
python -m pytest test_pedidos.py::test_crear_pedido_guarda_y_notifica -v
En este tema construimos stubs, fakes y mocks manuales para probar un servicio sin usar una base de datos ni un sistema real de notificaciones.
En el próximo tema veremos unittest.mock, una herramienta incluida en Python que permite crear mocks con menos código manual.