En este tema integraremos lo aprendido durante el curso. Construiremos un servicio Python que depende de varios componentes externos y escribiremos pruebas usando fakes, stubs y mocks según corresponda.
El objetivo no es usar todas las herramientas posibles, sino elegir bien cada doble de prueba para que las pruebas sean claras, rápidas y confiables.
Vamos a probar un servicio que procesa una compra. El flujo será:
El servicio recibe sus dependencias por constructor:
class CarritoVacioError(Exception):
pass
class PagoRechazadoError(Exception):
pass
class ServicioCompras:
def __init__(self, carritos, pedidos, pagos, email, cola, reloj, generar_id):
self.carritos = carritos
self.pedidos = pedidos
self.pagos = pagos
self.email = email
self.cola = cola
self.reloj = reloj
self.generar_id = generar_id
def procesar_compra(self, usuario_id, datos_pago):
carrito = self.carritos.buscar_por_usuario(usuario_id)
if carrito is None or not carrito["items"]:
raise CarritoVacioError("El carrito está vacío")
total = calcular_total(carrito["items"])
resultado_pago = self.pagos.cobrar(datos_pago["tarjeta"], total)
if not resultado_pago["aprobado"]:
raise PagoRechazadoError("El pago fue rechazado")
pedido = {
"id": self.generar_id(),
"usuario_id": usuario_id,
"email": carrito["email"],
"items": carrito["items"],
"total": total,
"estado": "confirmado",
"creado_en": self.reloj.ahora().isoformat(),
}
self.pedidos.guardar(pedido)
self.email.enviar_confirmacion(pedido["email"], pedido)
self.cola.publicar("pedidos", {
"tipo": "pedido_confirmado",
"pedido_id": pedido["id"],
"usuario_id": usuario_id,
"total": total,
})
return pedido
La función calcular_total queda separada porque es lógica pura.
def calcular_total(items):
return sum(item["precio"] * item["cantidad"] for item in items)
Esta función se prueba sin mocks:
def test_calcular_total():
items = [
{"producto": "Teclado", "precio": 1000, "cantidad": 2},
{"producto": "Mouse", "precio": 500, "cantidad": 1},
]
assert calcular_total(items) == 2500
El repositorio de carritos puede ser un fake en memoria:
class RepositorioCarritosFake:
def __init__(self, carritos=None):
self.carritos = carritos or {}
def buscar_por_usuario(self, usuario_id):
carrito = self.carritos.get(usuario_id)
return None if carrito is None else {
"usuario_id": carrito["usuario_id"],
"email": carrito["email"],
"items": list(carrito["items"]),
}
Conserva datos suficientes para los escenarios de prueba.
El repositorio de pedidos también puede ser un fake:
class RepositorioPedidosFake:
def __init__(self):
self.pedidos = {}
def guardar(self, pedido):
self.pedidos[pedido["id"]] = pedido.copy()
def buscar_por_id(self, pedido_id):
return self.pedidos.get(pedido_id)
Esto permite verificar que el pedido fue guardado sin usar una base real.
El reloj controla la fecha de creación:
from datetime import datetime
class RelojStub:
def ahora(self):
return datetime(2026, 5, 15, 10, 30, 0)
La prueba será determinística porque la fecha no depende del sistema.
Usaremos builders para que las pruebas sean más legibles:
def crear_item(**cambios):
item = {
"producto": "Teclado",
"precio": 1000,
"cantidad": 1,
}
item.update(cambios)
return item
def crear_carrito(**cambios):
carrito = {
"usuario_id": "USR-1",
"email": "ana@example.com",
"items": [crear_item()],
}
carrito.update(cambios)
return carrito
Los builders ocultan datos secundarios y dejan visible lo importante del escenario.
Combinamos fake, stub y mocks:
from unittest.mock import Mock
def test_procesar_compra_exitosa():
carritos = RepositorioCarritosFake({
"USR-1": crear_carrito(
items=[
crear_item(producto="Teclado", precio=1000, cantidad=2),
crear_item(producto="Mouse", precio=500, cantidad=1),
]
)
})
pedidos = RepositorioPedidosFake()
pagos = Mock()
pagos.cobrar.return_value = {"aprobado": True}
email = Mock()
cola = Mock()
servicio = ServicioCompras(
carritos=carritos,
pedidos=pedidos,
pagos=pagos,
email=email,
cola=cola,
reloj=RelojStub(),
generar_id=lambda: "PED-1",
)
pedido = servicio.procesar_compra(
"USR-1",
{"tarjeta": "4111111111111111"},
)
assert pedido["id"] == "PED-1"
assert pedido["total"] == 2500
assert pedido["estado"] == "confirmado"
assert pedidos.buscar_por_id("PED-1") == pedido
pagos.cobrar.assert_called_once_with("4111111111111111", 2500)
email.enviar_confirmacion.assert_called_once_with("ana@example.com", pedido)
cola.publicar.assert_called_once_with(
"pedidos",
{
"tipo": "pedido_confirmado",
"pedido_id": "PED-1",
"usuario_id": "USR-1",
"total": 2500,
},
)
carritos: fake, porque necesitamos datos en memoria.pedidos: fake, porque queremos verificar estado guardado.pagos: mock, porque representa una pasarela externa y verificamos la llamada.email: mock, porque verificamos una notificación externa.cola: mock, porque verificamos la publicación de un evento.reloj: stub, porque solo devuelve una fecha fija.generar_id: función stub, porque devuelve un identificador fijo.Si el carrito está vacío, no debe cobrarse ni enviar mensajes:
import pytest
def test_procesar_compra_con_carrito_vacio_no_cobra_ni_notifica():
carritos = RepositorioCarritosFake({
"USR-1": crear_carrito(items=[])
})
pedidos = RepositorioPedidosFake()
pagos = Mock()
email = Mock()
cola = Mock()
servicio = ServicioCompras(
carritos, pedidos, pagos, email, cola, RelojStub(), lambda: "PED-1"
)
with pytest.raises(CarritoVacioError):
servicio.procesar_compra("USR-1", {"tarjeta": "4111111111111111"})
assert pedidos.pedidos == {}
pagos.cobrar.assert_not_called()
email.enviar_confirmacion.assert_not_called()
cola.publicar.assert_not_called()
El mismo error puede aplicarse si no hay carrito:
def test_procesar_compra_sin_carrito():
servicio = ServicioCompras(
RepositorioCarritosFake({}),
RepositorioPedidosFake(),
Mock(),
Mock(),
Mock(),
RelojStub(),
lambda: "PED-1",
)
with pytest.raises(CarritoVacioError):
servicio.procesar_compra("USR-404", {"tarjeta": "4111111111111111"})
Esta prueba puede complementarse con aserciones de que no hubo efectos externos, como en el caso anterior.
Si el pago es rechazado, no debe guardarse pedido ni enviar confirmación:
def test_procesar_compra_con_pago_rechazado():
carritos = RepositorioCarritosFake({
"USR-1": crear_carrito(items=[crear_item(precio=1000, cantidad=1)])
})
pedidos = RepositorioPedidosFake()
pagos = Mock()
pagos.cobrar.return_value = {"aprobado": False}
email = Mock()
cola = Mock()
servicio = ServicioCompras(
carritos, pedidos, pagos, email, cola, RelojStub(), lambda: "PED-1"
)
with pytest.raises(PagoRechazadoError):
servicio.procesar_compra("USR-1", {"tarjeta": "4000000000000002"})
assert pedidos.pedidos == {}
pagos.cobrar.assert_called_once_with("4000000000000002", 1000)
email.enviar_confirmacion.assert_not_called()
cola.publicar.assert_not_called()
También podemos simular que la pasarela falla:
def test_procesar_compra_si_pagos_falla_no_guarda_pedido():
carritos = RepositorioCarritosFake({
"USR-1": crear_carrito(items=[crear_item(precio=1000)])
})
pedidos = RepositorioPedidosFake()
pagos = Mock()
pagos.cobrar.side_effect = TimeoutError("Tiempo agotado")
email = Mock()
cola = Mock()
servicio = ServicioCompras(
carritos, pedidos, pagos, email, cola, RelojStub(), lambda: "PED-1"
)
with pytest.raises(TimeoutError):
servicio.procesar_compra("USR-1", {"tarjeta": "4111111111111111"})
assert pedidos.pedidos == {}
email.enviar_confirmacion.assert_not_called()
cola.publicar.assert_not_called()
Si el diseño requiere traducir este error a una excepción propia, se puede probar esa traducción del mismo modo.
Podemos parametrizar el cálculo de totales para varios carritos:
import pytest
@pytest.mark.parametrize(
"items,total_esperado",
[
([crear_item(precio=1000, cantidad=1)], 1000),
([crear_item(precio=1000, cantidad=2)], 2000),
([crear_item(precio=1000), crear_item(precio=500)], 1500),
],
)
def test_calcular_total_parametrizado(items, total_esperado):
assert calcular_total(items) == total_esperado
Las reglas puras se prueban aparte para no repetir combinaciones en el flujo completo.
Estas pruebas unitarias no verifican todo. Faltaría cubrir con pruebas de integración:
La suite completa combina pruebas unitarias rápidas con algunas pruebas integradas.
Extiende el servicio para que, si el total supera 10000, publique además un evento pedido_alto_valor. Escribe una prueba que verifique:
pedido_confirmado y pedido_alto_valor.Una forma simple es agregar esta lógica luego de publicar pedido_confirmado:
if total > 10000:
self.cola.publicar("pedidos", {
"tipo": "pedido_alto_valor",
"pedido_id": pedido["id"],
"usuario_id": usuario_id,
"total": total,
})
La prueba puede verificar las llamadas a la cola:
from unittest.mock import call
def test_procesar_compra_de_alto_valor_publica_evento_extra():
carritos = RepositorioCarritosFake({
"USR-1": crear_carrito(items=[
crear_item(producto="Notebook", precio=12000, cantidad=1)
])
})
pedidos = RepositorioPedidosFake()
pagos = Mock()
pagos.cobrar.return_value = {"aprobado": True}
email = Mock()
cola = Mock()
servicio = ServicioCompras(
carritos, pedidos, pagos, email, cola, RelojStub(), lambda: "PED-99"
)
pedido = servicio.procesar_compra("USR-1", {"tarjeta": "4111111111111111"})
assert pedidos.buscar_por_id("PED-99") == pedido
pagos.cobrar.assert_called_once_with("4111111111111111", 12000)
email.enviar_confirmacion.assert_called_once_with("ana@example.com", pedido)
cola.publicar.assert_has_calls([
call("pedidos", {
"tipo": "pedido_confirmado",
"pedido_id": "PED-99",
"usuario_id": "USR-1",
"total": 12000,
}),
call("pedidos", {
"tipo": "pedido_alto_valor",
"pedido_id": "PED-99",
"usuario_id": "USR-1",
"total": 12000,
}),
])
En este caso integrador usamos varias herramientas del curso de forma coordinada. No mockeamos todo: usamos funciones puras para cálculos, fakes para repositorios, stubs para fecha e identificador, y mocks para pagos, email y cola.
La idea central del curso es elegir el doble correcto según el problema. Una buena prueba debe ser clara, rápida, determinística y estar enfocada en el comportamiento que realmente importa.