30. Caso práctico integrador: probar un servicio Python con dependencias externas

30.1 Objetivo del tema

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.

Objetivo práctico: probar un flujo completo de negocio sin usar base de datos, pasarela de pago, email ni cola reales.

30.2 Caso de negocio

Vamos a probar un servicio que procesa una compra. El flujo será:

  • Buscar el carrito del usuario.
  • Verificar que tenga productos.
  • Calcular el total.
  • Cobrar el pago con una pasarela externa.
  • Guardar el pedido.
  • Enviar un correo de confirmación.
  • Publicar un evento en una cola.

30.3 Código del servicio

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.

30.4 Función pura para el total

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

30.5 Fake de carritos

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.

30.6 Fake de pedidos

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.

30.7 Stub de reloj

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.

30.8 Builders de datos

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.

30.9 Prueba de compra exitosa

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,
        },
    )

30.10 Qué doble usamos en cada dependencia

  • 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.

30.11 Carrito vacío

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()

30.12 Carrito inexistente

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.

30.13 Pago rechazado

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()

30.14 Error del servicio de pagos

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.

30.15 Parametrizar totales

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.

30.16 Qué faltaría probar con integración

Estas pruebas unitarias no verifican todo. Faltaría cubrir con pruebas de integración:

  • El repositorio real de carritos y pedidos contra una base de prueba.
  • El adaptador real de la pasarela de pagos contra un entorno controlado o servidor falso.
  • La serialización real del evento enviado a la cola.
  • La configuración real del servicio en un entorno de prueba.

La suite completa combina pruebas unitarias rápidas con algunas pruebas integradas.

30.17 Checklist final

  • Las reglas puras se prueban sin mocks.
  • Las dependencias con estado usan fakes.
  • Las respuestas fijas usan stubs.
  • Los efectos externos usan mocks o spies.
  • Los datos repetitivos usan builders.
  • Los casos negativos verifican que no ocurran efectos indebidos.
  • La infraestructura real queda para pruebas de integración.

30.18 Ejercicio práctico

Extiende el servicio para que, si el total supera 10000, publique además un evento pedido_alto_valor. Escribe una prueba que verifique:

  • El pedido se guarda.
  • Se cobra el total correcto.
  • Se envía la confirmación.
  • Se publican los eventos pedido_confirmado y pedido_alto_valor.

30.19 Solución posible del ejercicio

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,
        }),
    ])

30.20 Conclusión

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.