12. Probar colaboraciones sin acoplarse a detalles internos innecesarios

12.1 Objetivo del tema

En el tema anterior vimos cómo verificar llamadas en mocks. Ahora veremos una cuestión igual de importante: decidir qué llamadas vale la pena verificar y cuáles son solo detalles internos.

Una prueba con mocks puede ser técnicamente correcta y, al mismo tiempo, demasiado frágil. Eso ocurre cuando la prueba describe paso a paso la implementación en vez de verificar el comportamiento esperado.

Objetivo práctico: escribir pruebas con mocks que validen colaboraciones importantes sin bloquear refactorizaciones legítimas.

12.2 Qué es una colaboración

Una colaboración ocurre cuando una unidad de código delega trabajo en otro componente. Por ejemplo, un servicio de pedidos puede colaborar con un repositorio, una pasarela de pago, un notificador y un registro de auditoría.

No todas las colaboraciones tienen la misma importancia para la prueba. Algunas son parte del comportamiento observable; otras son decisiones internas de implementación.

12.3 Caso de ejemplo

Usaremos un servicio que confirma pedidos:

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

    def confirmar(self, pedido_id):
        pedido = self.repositorio.buscar_por_id(pedido_id)

        if pedido is None:
            return False

        pago = self.pagos.cobrar(pedido["tarjeta"], pedido["total"])

        if not pago["aprobado"]:
            self.auditoria.registrar("pedido_rechazado", pedido_id)
            return False

        pedido["estado"] = "confirmado"
        self.repositorio.guardar(pedido)
        self.email.enviar_confirmacion(pedido["email"], pedido_id)
        self.auditoria.registrar("pedido_confirmado", pedido_id)
        return True

Este método tiene varias colaboraciones. La prueba debe elegir cuáles observar.

12.4 Una prueba demasiado acoplada

Una prueba frágil podría verificar cada llamada en detalle:

from unittest.mock import Mock


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

    resultado = servicio.confirmar(10)

    assert resultado is True
    repositorio.buscar_por_id.assert_called_once_with(10)
    pagos.cobrar.assert_called_once_with("4111111111111111", 2500)
    repositorio.guardar.assert_called_once_with(pedido)
    email.enviar_confirmacion.assert_called_once_with("ana@example.com", 10)
    auditoria.registrar.assert_called_once_with("pedido_confirmado", 10)

Esta prueba puede estar bien si todas esas interacciones forman parte del contrato. Pero si algunas son detalles internos, el test será difícil de mantener.

12.5 Pregunta clave

Antes de verificar una llamada, conviene preguntarse:

  • ¿Esta interacción es visible para el usuario o para otro sistema?
  • ¿Si esta llamada no ocurre, se rompe una regla de negocio?
  • ¿Estoy verificando comportamiento o estoy copiando la implementación?
  • ¿La prueba seguiría siendo válida si refactorizo el código sin cambiar el resultado?
Una interacción merece verificarse cuando es parte del resultado esperado, no solo porque existe en el código actual.

12.6 Usar fakes para observar estado

Si queremos comprobar que el pedido terminó confirmado, podemos usar un fake de repositorio y verificar estado en lugar de verificar la llamada exacta a guardar:

class RepositorioPedidosFake:
    def __init__(self, pedidos):
        self.pedidos = {pedido["id"]: pedido.copy() for pedido in pedidos}

    def buscar_por_id(self, pedido_id):
        pedido = self.pedidos.get(pedido_id)
        return None if pedido is None else pedido.copy()

    def guardar(self, pedido):
        self.pedidos[pedido["id"]] = pedido.copy()

Este fake permite comprobar el estado final sin atarse al objeto exacto pasado al método.

12.7 Prueba centrada en comportamiento

Una prueba menos acoplada puede verificar el resultado importante y las interacciones externas relevantes:

def test_confirmar_pedido_aprobado_confirma_y_notifica():
    repositorio = RepositorioPedidosFake([
        {
            "id": 10,
            "email": "ana@example.com",
            "tarjeta": "4111111111111111",
            "total": 2500,
            "estado": "pendiente",
        }
    ])
    pagos = Mock()
    pagos.cobrar.return_value = {"aprobado": True}
    email = Mock()
    auditoria = Mock()
    servicio = ServicioPedidos(repositorio, pagos, email, auditoria)

    resultado = servicio.confirmar(10)

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

La prueba verifica estado final y efectos externos. No exige que la persistencia se haya implementado con una llamada exacta en un momento exacto, salvo que eso importe.

12.8 Qué verificar en colaboraciones externas

Las colaboraciones externas suelen merecer más atención porque producen efectos fuera del proceso de prueba:

  • Enviar un email.
  • Cobrar un pago.
  • Publicar un evento.
  • Registrar auditoría.
  • Enviar una notificación.

En esos casos, verificar la llamada puede ser el comportamiento principal de la prueba.

12.9 Qué evitar verificar

En general, evita verificar llamadas que solo expresan cómo está escrito el algoritmo:

  • Métodos privados o auxiliares internos.
  • Pasos intermedios que no cambian el resultado observable.
  • Orden exacto de llamadas cuando el orden no importa.
  • Cada consulta interna cuando alcanza con verificar el resultado final.

12.10 Caso de pago rechazado

Para un pago rechazado, las interacciones importantes son diferentes:

def test_confirmar_pedido_rechazado_no_confirma_ni_notifica():
    repositorio = RepositorioPedidosFake([
        {
            "id": 20,
            "email": "luis@example.com",
            "tarjeta": "4000000000000002",
            "total": 900,
            "estado": "pendiente",
        }
    ])
    pagos = Mock()
    pagos.cobrar.return_value = {"aprobado": False}
    email = Mock()
    auditoria = Mock()
    servicio = ServicioPedidos(repositorio, pagos, email, auditoria)

    resultado = servicio.confirmar(20)

    assert resultado is False
    assert repositorio.buscar_por_id(20)["estado"] == "pendiente"
    email.enviar_confirmacion.assert_not_called()
    auditoria.registrar.assert_called_once_with("pedido_rechazado", 20)

La prueba verifica que no se confirma, no se envía email y sí se audita el rechazo.

12.11 Verificar menos, pero mejor

Una buena prueba no necesariamente tiene muchas aserciones. Debe tener las aserciones correctas. Si una sola aserción sobre el estado final cubre la regla de negocio, puede ser suficiente.

En cambio, si el comportamiento consiste en llamar a una pasarela de pago o publicar un evento, entonces la interacción sí debe verificarse.

12.12 Evitar mocks de objetos de datos

Si una dependencia es solo un dato, no hace falta mockearla. Por ejemplo, este pedido puede ser un diccionario o una dataclass:

pedido = {
    "id": 10,
    "email": "ana@example.com",
    "total": 2500,
    "estado": "pendiente",
}

Mockear datos simples suele hacer que la prueba sea menos clara. Reserva los mocks para colaboraciones con comportamiento.

12.13 Interfaces pequeñas

Las pruebas con mocks son más claras cuando las dependencias tienen interfaces pequeñas. Es más fácil verificar email.enviar_confirmacion(email, pedido_id) que un objeto grande con veinte métodos mezclados.

Si un mock requiere mucha configuración, puede indicar que la dependencia hace demasiadas cosas o que la unidad bajo prueba está demasiado acoplada.

12.14 Verificar orden solo cuando importa

A veces importa el orden. Por ejemplo, no se debe enviar una confirmación antes de aprobar el pago. Pero no siempre necesitamos verificarlo explícitamente.

Si el orden es una regla crítica, podemos usar mock_calls o diseñar una prueba específica. Si no lo es, verificar orden agrega fragilidad sin beneficio.

El orden de llamadas debe verificarse cuando el orden forma parte del comportamiento, no cuando solo coincide con la implementación actual.

12.15 Refactorización que no debería romper pruebas

Imagina que cambiamos el servicio para usar un método auxiliar interno:

def _confirmar_pedido(self, pedido):
    pedido["estado"] = "confirmado"
    self.repositorio.guardar(pedido)

Una prueba centrada en comportamiento no debería romperse por esta extracción. Si la prueba estaba verificando llamadas internas al detalle, probablemente sí se rompería aunque el sistema siga funcionando igual.

12.16 Guía práctica

  • Verifica resultados y estado final cuando sea posible.
  • Verifica llamadas a sistemas externos cuando sean parte del comportamiento.
  • Usa fakes para dependencias con estado.
  • Usa mocks para colaboraciones observables como email, pagos, eventos o auditoría.
  • Evita verificar métodos auxiliares o pasos internos sin valor de negocio.

12.17 Ejercicio práctico

Analiza esta función:

def cancelar_reserva(reserva_id, repositorio, pagos, email, auditoria):
    reserva = repositorio.buscar_por_id(reserva_id)

    if reserva is None:
        return False

    reserva["estado"] = "cancelada"
    repositorio.guardar(reserva)

    if reserva["pagada"]:
        pagos.reembolsar(reserva["pago_id"])

    email.enviar_cancelacion(reserva["email"], reserva_id)
    auditoria.registrar("reserva_cancelada", reserva_id)
    return True

Escribe una prueba para una reserva pagada. Decide qué verificar como estado final y qué verificar como interacción.

12.18 Solución posible del ejercicio

Una solución equilibrada:

from unittest.mock import Mock

from reservas import cancelar_reserva


class RepositorioReservasFake:
    def __init__(self, reservas):
        self.reservas = {reserva["id"]: reserva.copy() for reserva in reservas}

    def buscar_por_id(self, reserva_id):
        reserva = self.reservas.get(reserva_id)
        return None if reserva is None else reserva.copy()

    def guardar(self, reserva):
        self.reservas[reserva["id"]] = reserva.copy()


def test_cancelar_reserva_pagada_cancela_reembolsa_y_notifica():
    repositorio = RepositorioReservasFake([
        {
            "id": 7,
            "email": "ana@example.com",
            "estado": "confirmada",
            "pagada": True,
            "pago_id": "PAGO-10",
        }
    ])
    pagos = Mock()
    email = Mock()
    auditoria = Mock()

    resultado = cancelar_reserva(7, repositorio, pagos, email, auditoria)

    assert resultado is True
    assert repositorio.buscar_por_id(7)["estado"] == "cancelada"
    pagos.reembolsar.assert_called_once_with("PAGO-10")
    email.enviar_cancelacion.assert_called_once_with("ana@example.com", 7)
    auditoria.registrar.assert_called_once_with("reserva_cancelada", 7)

El estado de la reserva se verifica con el fake. Las interacciones externas relevantes se verifican con mocks.

12.19 Conclusión

Verificar colaboraciones es útil cuando esas colaboraciones forman parte del comportamiento esperado. El problema aparece cuando la prueba se ata a pasos internos que podrían cambiar sin afectar el resultado.

En el próximo tema comenzaremos a usar patch para reemplazar funciones, clases y objetos durante una prueba.