Un test para place_order necesita una pasarela de pago, un cliente de SMS y una calculadora de impuestos. Conecta las versiones reales y cada ejecución del test cobra una tarjeta y manda un SMS a un número real. Un test unitario tiene que correr en milisegundos y sin red, así que las tres dependencias hay que sustituirlas por otra cosa. Ese “otra cosa” llega en cinco variantes. Cada una resuelve un problema distinto, y tratarlas como intercambiables explica por qué las discusiones de “pon un mock y ya” en code review no llegan a ningún lado.

Qué es un test double

Gerard Meszaros acuñó el término “test double” en xUnit Test Patterns como el término genérico para cualquier cosa que sustituye a una dependencia real durante un test, igual que un doble de riesgo sustituye a un actor. Los equipos de rodaje usan dobles distintos según la toma: uno solo tiene que quedarse de fondo con el peinado correcto, otro hace de verdad el choque de coches. Los test doubles se dividen igual. Dummy, stub, spy, fake y mock son cinco trabajos distintos, y lo que los separa es qué comprueba el test, no cuán elaborado sea el objeto sustituto.

Esta es la clase que prueba cada ejemplo siguiente. Tres de sus dependencias merece la pena sustituirlas; la cuarta resulta no importar en absoluto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class PaymentDeclined(Exception):
    pass


class OrderProcessor:
    def __init__(self, gateway, sms_client, tax_calculator, logger):
        self.gateway = gateway
        self.sms_client = sms_client
        self.tax_calculator = tax_calculator
        self.logger = logger

    def place_order(self, customer_phone, subtotal):
        total = self.tax_calculator.total_with_tax(subtotal)
        try:
            charge_id = self.gateway.charge(total)
        except PaymentDeclined:
            self.logger.warning("payment declined for %s", customer_phone)
            raise
        self.sms_client.send(customer_phone, f"Order confirmed, charged ${total:.2f}")
        return charge_id

logger solo se ejecuta en el camino de error. Eso es clave para el primer ejemplo.

Dos sustitutos pequeños se reutilizan en los próximos tests, así que se definen una sola vez aquí:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from unittest.mock import Mock, create_autospec


class StubGateway:
    def __init__(self, charge_id):
        self.charge_id = charge_id

    def charge(self, amount):
        return self.charge_id


class StubTaxCalculator:
    def __init__(self, total):
        self.total = total

    def total_with_tax(self, subtotal):
        return self.total

Qué es un objeto dummy en testing

Un dummy se pasa solo para satisfacer un parámetro que el test nunca ejercita. No hace nada y no comprueba nada. El test del camino feliz de abajo nunca llega a la rama de la excepción, así que logger puede ser cualquier cosa que sobreviva a que se le asigne un atributo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def test_place_order_returns_the_charge_id():
    dummy_logger = object()  # nunca se llama en este camino
    processor = OrderProcessor(
        gateway=StubGateway(charge_id="ch_1"),
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=dummy_logger,
    )

    charge_id = processor.place_order("+15551234567", subtotal=100.00)

    assert charge_id == "ch_1"

object() funciona porque nadie le llama ningún método. Si un cambio posterior hiciera que place_order también registrara un log en el camino de éxito, este test fallaría con un AttributeError. Ese fallo es útil: te dice que el dummy dejó de ser un dummy.

Qué es un stub en testing unitario

Un stub devuelve una respuesta fija para que el resto del test siga siendo determinista. La calculadora de impuestos real podría llamar a una API de tasas fiscales. El test no quiere eso, así que StubTaxCalculator devuelve 108.00 sea cual sea el subtotal que recibe. Ya lo has visto funcionando en el test del dummy de arriba, junto con StubGateway haciendo el mismo trabajo para charge.

Nada comprueba si se llamó a total_with_tax, ni con qué argumento. Un stub le da al código bajo test una entrada controlada; nunca verifica una interacción. Esa es la línea que separa un stub de los otros tres doubles.

Qué es un test spy

Un spy registra las llamadas que recibe para que el test pueda inspeccionarlas después de que el código bajo test se haya ejecutado. unittest.mock.Mock registra automáticamente cada llamada, así que la mayoría de los suites en Python lo usan como spy aunque la palabra nunca aparezca en el test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_place_order_sends_a_confirmation_sms():
    sms_spy = Mock()
    processor = OrderProcessor(
        gateway=StubGateway(charge_id="ch_1"),
        sms_client=sms_spy,
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    processor.place_order("+15551234567", subtotal=100.00)

    sms_spy.send.assert_called_once_with(
        "+15551234567", "Order confirmed, charged $108.00"
    )

No se configuró nada en sms_spy antes de la llamada. Aceptó lo que le dieron, lo registró, y el test lo interrogó después.

Qué es un fake object en testing

Un stub no puede decirte si dos cobros seguidos obtienen dos IDs distintos, porque solo conoce un valor. Cuando un test necesita comportamiento a través de varias llamadas, lo que hace falta es un fake: una implementación pequeña que funciona de verdad.

1
2
3
4
5
6
7
8
class FakeGateway:
    def __init__(self):
        self.charges = []

    def charge(self, amount):
        charge_id = f"ch_{len(self.charges) + 1}"
        self.charges.append((charge_id, amount))
        return charge_id
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def test_two_orders_get_two_different_charge_ids():
    gateway = FakeGateway()
    processor = OrderProcessor(
        gateway=gateway,
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    first_id = processor.place_order("+15551234567", subtotal=100.00)
    second_id = processor.place_order("+15557654321", subtotal=100.00)

    assert first_id != second_id
    assert gateway.charges == [(first_id, 108.00), (second_id, 108.00)]

FakeGateway no hace ninguna llamada de red ni tiene un procesador de pagos detrás, pero mantiene estado igual que lo haría el real. El test necesitaba exactamente ese estado, y un stub no podía darlo.

Qué es un mock object

En la definición de Meszaros, un mock lleva sus expectativas dentro del test: las reglas de una llamada válida se fijan antes de que el código se ejecute, y una llamada que las rompe falla en el acto. unittest.mock es más laxo, porque igual verificas después con un assert, pero create_autospec te da la mitad importante. Construye el double a partir de una clase real, así que cada llamada se comprueba contra las firmas reales de los métodos en el momento en que ocurre:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Gateway:
    def charge(self, amount: float) -> str:
        raise NotImplementedError


def test_place_order_charges_the_amount_after_tax():
    gateway = create_autospec(Gateway, instance=True)
    gateway.charge.return_value = "ch_1"
    processor = OrderProcessor(
        gateway=gateway,
        sms_client=Mock(),
        tax_calculator=StubTaxCalculator(total=108.00),
        logger=object(),
    )

    processor.place_order("+15551234567", subtotal=100.00)

    gateway.charge.assert_called_once_with(108.00)

Cambia place_order para que llame a self.gateway.charge(total, currency="usd") y este test falla con un TypeError dentro de place_order, porque Gateway.charge no acepta el argumento currency. Ahora imagina un Mock() normal en el mismo sitio, con alguien actualizando el assert para que cuadre con la nueva llamada. El test pasa, y el código se rompe la primera vez que se encuentra con la pasarela real. Un spy confirma que una llamada ocurrió. Un mock con autospec confirma además que esa llamada es una que el objeto real aceptaría.

Mock vs stub vs spy vs fake: tabla comparativa

DoubleQué haceÚsalo cuando
DummySe pasa solo para satisfacer un parámetro; nunca se llamaUna dependencia existe solo porque la firma la exige en este camino
StubDevuelve siempre la misma respuesta fija, sea cual sea la entradaEl test necesita un valor fijo y determinista de una dependencia
SpyRegistra las llamadas para que el test pueda inspeccionarlas despuésEl test necesita confirmar que una interacción ocurrió, sin configurarla antes
FakeUna implementación más pequeña pero funcional de la cosa realEl test necesita comportamiento real a través de varias llamadas, no un solo valor fijo
MockSe construye con expectativas sobre llamadas válidas, comprobadas en el momento en que ocurrenEl test necesita verificar la forma exacta de una llamada: argumentos, número de veces, orden

gateway fue un stub, un fake y un mock en tres tests de la misma clase. El double depende de qué comprueba el test, no de la dependencia.

Cuándo los test doubles empeoran los tests

Mockear cada colaborador acopla el test a cómo está escrito el código en lugar de a lo que hace. Añade un test que verifique que total_with_tax se llama exactamente una vez, y luego refactoriza place_order para que lo llame una segunda vez por motivos de logging: el test se rompe, y nada que le importe a un usuario ha cambiado. El test del fake de arriba no tiene ese problema, porque verifica el resultado (dos IDs de cobro distintos, los importes correctos registrados) en lugar de la forma de la llamada.

Mockear una librería que no es tuya tiene el mismo problema, un nivel más arriba. Haz autospec directamente de un cliente de terceros y cada test que lo toca depende de las firmas de método de esa librería; una actualización menor que renombra un parámetro rompe tests por toda la suite. Envuelve la librería en una pequeña interfaz propia y dobla esa interfaz en su lugar. Cuando la librería cambie, solo cambia el wrapper.

A veces el número correcto de doubles es cero. Si la dependencia real ya es rápida y no tiene efectos secundarios, como una función pura o una conexión SQLite en memoria, doblarla solo añade indirección y no detecta nada. Los doubles son para dependencias lentas, remotas, no deterministas, o con efectos secundarios que no quieres en una ejecución de tests.

Cómo elegir el test double correcto

Empieza por lo que comprueba el test, no por lo que parece más seguro de fakear. Si necesitas una entrada predecible para que un assert posterior se cumpla, usa un stub. Para confirmar que el SMS salió, usa un spy. Si la dependencia tiene que comportarse correctamente a través de varias llamadas, escribe un fake una vez y reutilízalo donde aparezca esa dependencia. Y cuando una llamada con argumentos incorrectos debería hacer fallar el test antes de que falle en producción, usa un mock con autospec.

Nada de esto funciona en una clase que construye sus propias dependencias internamente, porque no hay dónde meter un double. Ese es el problema que resuelve la dependency injection, y es lo primero que hay que arreglar si una clase se resiste a todos los doubles de este artículo. Una vez existe esa costura, decide qué double corresponde a qué nivel de test. Si trabajas test-first, vas a recurrir a los doubles pronto, porque el test existe antes de que haya código real al que llamar.

Artículos relacionados