27. Cuándo no conviene usar mocks y cómo reconocer pruebas demasiado acopladas

27.1 Objetivo del tema

Los mocks son útiles, pero no siempre son la mejor herramienta. Una prueba con demasiados mocks puede terminar verificando cómo está escrito el código en lugar de verificar qué comportamiento ofrece.

En este tema veremos cuándo evitar mocks, cómo reconocer pruebas demasiado acopladas y qué alternativas usar: funciones puras, objetos reales simples, stubs, fakes o pruebas de integración.

Objetivo práctico: decidir cuándo un mock aporta claridad y cuándo está volviendo la prueba frágil.

27.2 Mock no significa mejor prueba

Un mock permite reemplazar una dependencia y verificar interacciones. Eso es valioso cuando la interacción forma parte del comportamiento esperado: enviar un correo, publicar un evento, cobrar un pago o registrar auditoría.

Pero si usamos mocks para cada función auxiliar, cada objeto de datos y cada paso interno, la prueba queda atada a detalles que podrían cambiar sin afectar el resultado real.

27.3 Señal 1: la prueba verifica demasiadas llamadas internas

Ejemplo de prueba frágil:

def test_calcular_total_demasiado_acoplado():
    calculadora = Mock()
    calculadora.obtener_items.return_value = [
        {"precio": 100, "cantidad": 2},
    ]
    calculadora.calcular_subtotal.return_value = 200
    calculadora.aplicar_impuestos.return_value = 242
    calculadora.redondear.return_value = 242

    total = procesar_total(calculadora)

    assert total == 242
    calculadora.obtener_items.assert_called_once_with()
    calculadora.calcular_subtotal.assert_called_once()
    calculadora.aplicar_impuestos.assert_called_once()
    calculadora.redondear.assert_called_once()

La prueba describe una secuencia interna. Si refactorizamos el cálculo sin cambiar el total, la prueba puede romperse igual.

27.4 Alternativa: probar el resultado con datos reales

Si el cálculo es puro, conviene probarlo con datos reales:

def calcular_total(items, tasa_iva):
    subtotal = sum(item["precio"] * item["cantidad"] for item in items)
    return subtotal + subtotal * tasa_iva


def test_calcular_total():
    items = [{"precio": 100, "cantidad": 2}]

    total = calcular_total(items, tasa_iva=0.21)

    assert total == 242

No hay mocks porque no hay dependencias externas ni efectos secundarios.

27.5 Señal 2: se mockean objetos de datos

Este tipo de prueba suele ser innecesaria:

def test_obtener_nombre_visible_con_mock():
    usuario = Mock()
    usuario.nombre = "Ana"
    usuario.activo = True

    assert obtener_nombre_visible(usuario) == "Ana"

Si el objeto solo contiene datos, un diccionario, una dataclass o una clase simple suele ser mejor.

27.6 Alternativa: dataclass o diccionario

Con dataclass:

from dataclasses import dataclass


@dataclass
class Usuario:
    nombre: str
    activo: bool


def test_obtener_nombre_visible():
    usuario = Usuario(nombre="Ana", activo=True)

    assert obtener_nombre_visible(usuario) == "Ana"

La prueba expresa un dato, no una colaboración.

27.7 Señal 3: hay muchos patches para una prueba simple

Si una prueba necesita parchear varias clases y funciones para ejecutar un método pequeño, puede indicar que el código crea demasiadas dependencias internamente.

with patch("modulo.Repositorio") as RepoMock, \
     patch("modulo.Email") as EmailMock, \
     patch("modulo.Auditoria") as AuditoriaMock, \
     patch("modulo.uuid4") as uuid4_mock:
    servicio = Servicio()
    servicio.ejecutar()

En estos casos conviene evaluar si el código debería recibir dependencias explícitas por constructor o parámetro.

27.8 Alternativa: inyección de dependencias

En lugar de crear dependencias dentro del servicio:

class Servicio:
    def __init__(self):
        self.repositorio = Repositorio()
        self.email = Email()
        self.auditoria = Auditoria()

Podemos recibirlas:

class Servicio:
    def __init__(self, repositorio, email, auditoria):
        self.repositorio = repositorio
        self.email = email
        self.auditoria = auditoria

La prueba puede pasar fakes o mocks sin aplicar tantos parches.

27.9 Señal 4: la prueba falla ante refactorizaciones internas

Una prueba está demasiado acoplada si falla cuando cambiamos la implementación pero el comportamiento sigue siendo correcto.

Por ejemplo, si antes un servicio llamaba a calcular_subtotal y ahora calcula el subtotal en otra función, una prueba que verificaba esa llamada fallará aunque el total final sea correcto.

Una buena prueba debería resistir refactorizaciones que no cambian el comportamiento observable.

27.10 Señal 5: el setup es más complejo que el comportamiento

Si preparar mocks ocupa muchas más líneas que el código que se quiere verificar, hay que revisar la prueba. Tal vez el escenario está mezclando demasiadas responsabilidades.

Opciones:

  • Dividir el código en unidades más pequeñas.
  • Usar builders de datos.
  • Usar fakes en memoria.
  • Probar el flujo con una prueba de integración más amplia.

27.11 Cuándo sí usar mocks

Los mocks son adecuados cuando queremos verificar una interacción importante:

  • Se llamó a una pasarela de pago con el monto correcto.
  • Se envió una notificación al destinatario correcto.
  • Se publicó un evento con los datos esperados.
  • No se llamó a un servicio externo en un caso inválido.
  • Se registró una auditoría obligatoria.

En esos casos, la interacción es parte del comportamiento.

27.12 Cuándo preferir stubs

Usa un stub cuando solo necesitas controlar una respuesta:

class RepositorioClienteStub:
    def __init__(self, cliente):
        self.cliente = cliente

    def buscar_por_id(self, cliente_id):
        return self.cliente

No hace falta verificar la llamada si lo importante es cómo el código reacciona al cliente devuelto.

27.13 Cuándo preferir fakes

Usa un fake cuando la dependencia necesita conservar estado:

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)

Un fake puede hacer que la prueba verifique estado final en lugar de llamadas internas.

27.14 Cuándo preferir integración

Si quieres saber si una consulta SQL funciona, si el repositorio real mapea bien columnas o si una configuración real carga correctamente, una prueba con mocks no responde esa pregunta.

En esos casos corresponde una prueba de integración controlada. Los mocks son útiles para aislar lógica, no para demostrar que la infraestructura real funciona.

27.15 Ejemplo de prueba demasiado acoplada

Función:

def confirmar_pedido(pedido, pagos, email, auditoria):
    resultado = pagos.cobrar(pedido["tarjeta"], pedido["total"])

    if not resultado["aprobado"]:
        auditoria.registrar("pago_rechazado", pedido["id"])
        return False

    pedido["estado"] = "confirmado"
    email.enviar_confirmacion(pedido["email"], pedido["id"])
    auditoria.registrar("pedido_confirmado", pedido["id"])
    return True

Una prueba que verifica cada paso puede estar bien si esos pasos son el contrato. Pero verificar detalles como el orden exacto de auditoría y email puede ser innecesario si el orden no importa.

27.16 Prueba más enfocada

Una prueba razonable verifica resultado, estado y efectos externos importantes:

from unittest.mock import Mock


def test_confirmar_pedido_aprobado():
    pagos = Mock()
    pagos.cobrar.return_value = {"aprobado": True}
    email = Mock()
    auditoria = Mock()
    pedido = {
        "id": 10,
        "email": "ana@example.com",
        "tarjeta": "4111111111111111",
        "total": 2500,
        "estado": "pendiente",
    }

    resultado = confirmar_pedido(pedido, pagos, email, auditoria)

    assert resultado is True
    assert pedido["estado"] == "confirmado"
    email.enviar_confirmacion.assert_called_once_with("ana@example.com", 10)
    auditoria.registrar.assert_called_once_with("pedido_confirmado", 10)

No verificamos pasos que no agregan información relevante para este comportamiento.

27.17 Checklist para decidir

  • ¿La dependencia produce un efecto externo? Un mock puede ser adecuado.
  • ¿Solo necesito una respuesta fija? Usa un stub.
  • ¿Necesito estado en memoria? Usa un fake.
  • ¿El dato es simple? Usa un objeto real, diccionario o dataclass.
  • ¿Quiero verificar infraestructura real? Usa una prueba de integración.
  • ¿La prueba se rompe por cambios internos sin cambiar el comportamiento? Revisa el acoplamiento.

27.18 Ejercicio práctico

La siguiente prueba está demasiado acoplada:

def test_procesar_carrito_acoplado():
    carrito = Mock()
    carrito.obtener_items.return_value = [
        {"precio": 100, "cantidad": 2},
    ]
    carrito.calcular_total.return_value = 200
    carrito.aplicar_descuento.return_value = 180

    total = procesar_carrito(carrito)

    assert total == 180
    carrito.obtener_items.assert_called_once()
    carrito.calcular_total.assert_called_once()
    carrito.aplicar_descuento.assert_called_once()

Propón una versión menos acoplada usando datos reales.

27.19 Solución posible del ejercicio

Si el comportamiento puede expresarse con una función pura, una prueba más clara sería:

def procesar_carrito(items, porcentaje_descuento):
    total = sum(item["precio"] * item["cantidad"] for item in items)
    return total - total * porcentaje_descuento


def test_procesar_carrito():
    items = [
        {"precio": 100, "cantidad": 2},
    ]

    total = procesar_carrito(items, porcentaje_descuento=0.10)

    assert total == 180

La prueba verifica el resultado del comportamiento sin acoplarse a métodos internos de un objeto mockeado.

27.20 Conclusión

No conviene usar mocks cuando un dato real simple, un stub, un fake o una prueba de integración comunican mejor el comportamiento. Los mocks son valiosos para interacciones importantes, pero pueden generar pruebas frágiles si se usan para verificar detalles internos.

En el próximo tema veremos cómo refactorizar código difícil de mockear para mejorar su diseño.