27. Uso moderado de dobles de prueba para dependencias externas

27.1 Objetivo del tema

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.

27.2 Qué es un doble de prueba

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.

Usamos dobles cuando queremos probar una regla propia sin depender del comportamiento, velocidad o disponibilidad de otro sistema.

27.3 Tipos comunes de dobles

  • Fake: implementación simple pero funcional, como un repositorio en memoria.
  • Stub: devuelve respuestas preparadas para la prueba.
  • Spy: registra cómo fue usado para verificar una interacción.
  • Mock: objeto con expectativas configuradas sobre llamadas.

En este curso usaremos primero dobles simples escritos a mano. Son más explícitos y suelen ser suficientes para muchos casos de TDD.

27.4 Caso práctico

El requisito inicial será:

Al confirmar un pedido, el sistema debe guardarlo como confirmado y enviar un correo de confirmación.

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.

27.5 Primera prueba con fake y spy

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.

27.6 Implementación mínima

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.

27.7 Por qué no usar una base de datos real aquí

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.

27.8 Segunda regla: pedido inexistente

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.

27.9 Ajustar el fake para el nuevo comportamiento

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.

27.10 Implementar la regla de error

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.

27.11 Stub para una dependencia de consulta

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.

27.12 Probar una regla con stub

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.

27.13 Adaptar el caso de uso

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

27.14 Mantener compatibilidad en las pruebas anteriores

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.

27.15 Uso de unittest.mock con moderación

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.

27.16 Riesgo de mocks demasiado estrictos

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.

27.17 Preferir verificación de estado cuando alcanza

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.

27.18 Dobles y límites del sistema

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.

27.19 Señales de buen uso

  • El doble reemplaza una dependencia externa real.
  • La prueba sigue leyendo como una regla del comportamiento.
  • El doble es simple y específico para el escenario.
  • El caso de uso recibe sus dependencias desde afuera.
  • El test verifica efectos importantes, no cada línea del algoritmo.

27.20 Señales de abuso

  • Se usan mocks para objetos simples del dominio.
  • La prueba configura demasiadas llamadas esperadas.
  • Un refactor interno rompe muchas pruebas sin cambiar comportamiento.
  • El test es más difícil de entender que el código probado.
  • Se reemplaza todo con mocks y no queda ningún resultado real para observar.

27.21 Ejercicio práctico

Construí con TDD un caso de uso RegistrarUsuario.

  1. Debe guardar el usuario en un repositorio.
  2. Debe enviar un correo de bienvenida.
  3. Si el email ya existe, debe lanzar ValueError.
  4. Usá un repositorio fake y un enviador de correo spy.
  5. Ejecutá python -m pytest después de cada regla.

27.22 Checklist del tema

  • Los dobles se usan para dependencias externas o difíciles de controlar.
  • Los fakes y spies escritos a mano suelen ser suficientes.
  • Los mocks se usan con moderación para interacciones realmente importantes.
  • La lógica de dominio no debería depender de dobles complejos.
  • Una prueba con dobles debe seguir explicando comportamiento, no implementación.

27.23 Conclusión

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.