2. Diferencias prácticas entre dummy, stub, fake, spy y mock

2.1 Objetivo del tema

En el tema anterior usamos un stub manual para controlar una dependencia. Ahora vamos a ordenar los distintos tipos de dobles de prueba y veremos cuándo conviene usar cada uno.

La diferencia importante no está en el nombre de la clase, sino en el rol que cumple dentro de la prueba. Un mismo objeto puede parecerse a más de un tipo de doble, pero conviene aprender a reconocer la intención principal.

Objetivo práctico: elegir entre dummy, stub, fake, spy y mock según lo que la prueba necesita controlar o verificar.

2.2 Escenario común para los ejemplos

Usaremos una función que registra un pedido. La función calcula el total, guarda el pedido en un repositorio y envía una notificación:

def registrar_pedido(items, repositorio, notificador, auditoria):
    total = sum(item["precio"] * item["cantidad"] for item in items)

    pedido = {
        "items": items,
        "total": total,
        "estado": "registrado",
    }

    pedido_id = repositorio.guardar(pedido)
    notificador.enviar_confirmacion(pedido_id, total)
    auditoria.registrar_evento("pedido_registrado")

    return pedido_id

Esta función tiene varias colaboraciones. Según el aspecto que queramos probar, necesitaremos diferentes dobles de prueba.

2.3 Dummy: se pasa, pero no importa

Un dummy es un objeto que se entrega como argumento porque la función lo exige, pero la prueba no necesita usarlo ni verificarlo. Sirve para completar una llamada.

Supongamos que queremos probar una función que solo valida un cupón, pero por diseño recibe un objeto de auditoría que no se usa en ese camino:

def aplicar_cupon(total, cupon, auditoria):
    if cupon == "PROMO10":
        return total * 0.90
    return total

La prueba puede pasar un dummy porque no nos interesa la auditoría en este caso:

class AuditoriaDummy:
    pass


def test_aplicar_cupon_valido():
    resultado = aplicar_cupon(1000, "PROMO10", AuditoriaDummy())

    assert resultado == 900
Un dummy no devuelve datos relevantes ni registra interacciones importantes. Solo ocupa un lugar necesario para ejecutar la prueba.

2.4 Stub: devuelve una respuesta preparada

Un stub se usa cuando la prueba necesita que una dependencia devuelva un dato específico. No buscamos verificar cómo fue llamado, sino controlar el resultado que entrega.

Por ejemplo, esta función decide si un usuario puede comprar según su saldo:

def puede_comprar(usuario_id, monto, billetera):
    saldo = billetera.obtener_saldo(usuario_id)
    return saldo >= monto

Podemos probarla con un stub que devuelva un saldo preparado:

class BilleteraConSaldoStub:
    def obtener_saldo(self, usuario_id):
        return 1500


def test_usuario_puede_comprar_si_tiene_saldo_suficiente():
    billetera = BilleteraConSaldoStub()

    resultado = puede_comprar("USR-1", 900, billetera)

    assert resultado is True

La prueba no depende de una billetera real ni de una base de datos. Solo controla el saldo que la función recibirá.

2.5 Stub configurable

Una variante habitual es construir un stub configurable para reutilizarlo en varios escenarios:

class BilleteraStub:
    def __init__(self, saldo):
        self.saldo = saldo

    def obtener_saldo(self, usuario_id):
        return self.saldo


def test_usuario_no_puede_comprar_si_no_tiene_saldo_suficiente():
    billetera = BilleteraStub(saldo=300)

    resultado = puede_comprar("USR-2", 900, billetera)

    assert resultado is False

Este enfoque deja visible el dato importante del escenario: el saldo disponible.

2.6 Fake: implementación simple pero funcional

Un fake es una implementación que funciona, pero está simplificada para pruebas. Un ejemplo típico es un repositorio en memoria que reemplaza una base de datos real.

class RepositorioPedidosFake:
    def __init__(self):
        self.pedidos = {}
        self.proximo_id = 1

    def guardar(self, pedido):
        pedido_id = self.proximo_id
        self.proximo_id += 1
        self.pedidos[pedido_id] = pedido
        return pedido_id

    def buscar_por_id(self, pedido_id):
        return self.pedidos.get(pedido_id)

Este fake no usa una base de datos, pero permite guardar y buscar pedidos durante la prueba. Tiene comportamiento real suficiente para el escenario.

2.7 Prueba usando un fake

Podemos usar el fake para verificar que un pedido queda guardado:

class NotificadorDummy:
    def enviar_confirmacion(self, pedido_id, total):
        pass


class AuditoriaDummy:
    def registrar_evento(self, evento):
        pass


def test_registrar_pedido_guarda_el_pedido():
    repositorio = RepositorioPedidosFake()
    notificador = NotificadorDummy()
    auditoria = AuditoriaDummy()

    pedido_id = registrar_pedido(
        items=[{"precio": 100, "cantidad": 3}],
        repositorio=repositorio,
        notificador=notificador,
        auditoria=auditoria,
    )

    pedido_guardado = repositorio.buscar_por_id(pedido_id)

    assert pedido_guardado["total"] == 300
    assert pedido_guardado["estado"] == "registrado"

El fake ayuda cuando necesitamos más comportamiento que una simple respuesta fija.

2.8 Spy: registra lo que ocurrió

Un spy registra cómo fue usado para que la prueba pueda inspeccionarlo después. Es útil cuando el comportamiento esperado es una interacción: por ejemplo, que se haya enviado una notificación.

class NotificadorSpy:
    def __init__(self):
        self.confirmaciones_enviadas = []

    def enviar_confirmacion(self, pedido_id, total):
        self.confirmaciones_enviadas.append({
            "pedido_id": pedido_id,
            "total": total,
        })

El spy no solo permite que el código avance. Además, guarda información para que podamos verificarla.

2.9 Prueba usando un spy

Ahora podemos comprobar que la confirmación fue enviada con los datos correctos:

def test_registrar_pedido_envia_confirmacion():
    repositorio = RepositorioPedidosFake()
    notificador = NotificadorSpy()
    auditoria = AuditoriaDummy()

    pedido_id = registrar_pedido(
        items=[{"precio": 200, "cantidad": 2}],
        repositorio=repositorio,
        notificador=notificador,
        auditoria=auditoria,
    )

    assert notificador.confirmaciones_enviadas == [
        {"pedido_id": pedido_id, "total": 400}
    ]

La prueba verifica una interacción observable: el sistema pidió enviar una confirmación.

2.10 Mock: configura y verifica expectativas

Un mock suele permitir configurar respuestas y verificar llamadas esperadas. En Python podemos crear mocks manuales o usar unittest.mock. Por ahora veremos la idea con la biblioteca estándar:

from unittest.mock import Mock


def test_registrar_pedido_registra_evento_de_auditoria():
    repositorio = RepositorioPedidosFake()
    notificador = Mock()
    auditoria = Mock()

    registrar_pedido(
        items=[{"precio": 50, "cantidad": 2}],
        repositorio=repositorio,
        notificador=notificador,
        auditoria=auditoria,
    )

    auditoria.registrar_evento.assert_called_once_with("pedido_registrado")

En este ejemplo, el mock nos permite verificar que el método registrar_evento fue llamado exactamente una vez con el argumento esperado.

2.11 Diferencia entre stub y mock

Una diferencia práctica es la pregunta que responde cada uno:

  • Stub: ¿qué dato debe devolver la dependencia para armar el escenario?
  • Mock: ¿qué interacción esperada debe ocurrir durante la ejecución?

Si solo necesitamos que una dependencia devuelva 1500, probablemente alcanza con un stub. Si necesitamos verificar que se llamó a enviar_confirmacion con ciertos argumentos, usamos un spy o un mock.

Una regla útil: usa stubs para preparar datos; usa spies o mocks para observar interacciones.

2.12 Diferencia entre fake y stub

Un stub devuelve respuestas preparadas, normalmente simples. Un fake tiene una implementación más completa, aunque simplificada.

Si una prueba solo necesita que buscar_por_id devuelva un usuario fijo, un stub es suficiente. Si varias pruebas necesitan crear, guardar, actualizar y consultar usuarios sin usar una base de datos real, un fake en memoria puede ser más claro.

class RepositorioUsuariosFake:
    def __init__(self):
        self.usuarios = {}

    def guardar(self, usuario):
        self.usuarios[usuario["id"]] = usuario

    def buscar_por_id(self, usuario_id):
        return self.usuarios.get(usuario_id)

2.13 Comparación rápida

La siguiente guía ayuda a elegir:

  • Usa dummy cuando el parámetro es obligatorio, pero no importa para la prueba.
  • Usa stub cuando necesitas una respuesta fija o controlada.
  • Usa fake cuando necesitas una implementación simple que conserve estado.
  • Usa spy cuando quieres revisar qué ocurrió después de ejecutar el código.
  • Usa mock cuando quieres configurar y verificar interacciones con una herramienta preparada para eso.

2.14 Error frecuente: verificar demasiadas interacciones

Un error habitual es convertir todas las colaboraciones en mocks y verificar cada llamada interna. Eso vuelve las pruebas frágiles porque quedan atadas a la implementación exacta.

Por ejemplo, si el resultado importante es que el pedido fue guardado y tiene el total correcto, no siempre hace falta verificar cada método auxiliar llamado para calcularlo. La prueba debe enfocarse en el comportamiento que realmente importa.

No verifiques una interacción solo porque puedes hacerlo. Verifícala cuando esa interacción sea parte del comportamiento esperado.

2.15 Ejercicio práctico

Analiza esta función:

def activar_cuenta(usuario_id, repositorio, email, auditoria):
    usuario = repositorio.buscar_por_id(usuario_id)

    if usuario is None:
        return False

    usuario["activo"] = True
    repositorio.guardar(usuario)
    email.enviar_bienvenida(usuario["email"])
    auditoria.registrar("cuenta_activada")

    return True

Indica qué tipo de doble usarías para cada dependencia en una prueba unitaria que verifica la activación exitosa de una cuenta.

2.16 Solución posible del ejercicio

Una solución razonable podría ser:

  • repositorio: un fake en memoria, porque necesitamos buscar, modificar y guardar un usuario.
  • email: un spy o mock, porque queremos verificar que se pidió enviar la bienvenida.
  • auditoria: un mock, si el registro de auditoría es parte del comportamiento requerido.

Una implementación de prueba podría verse así:

from unittest.mock import Mock

from cuentas import activar_cuenta


class RepositorioUsuariosFake:
    def __init__(self, usuarios):
        self.usuarios = usuarios

    def buscar_por_id(self, usuario_id):
        return self.usuarios.get(usuario_id)

    def guardar(self, usuario):
        self.usuarios[usuario["id"]] = usuario


def test_activar_cuenta_existente():
    repositorio = RepositorioUsuariosFake({
        1: {"id": 1, "email": "ana@example.com", "activo": False}
    })
    email = Mock()
    auditoria = Mock()

    resultado = activar_cuenta(1, repositorio, email, auditoria)

    assert resultado is True
    assert repositorio.buscar_por_id(1)["activo"] is True
    email.enviar_bienvenida.assert_called_once_with("ana@example.com")
    auditoria.registrar.assert_called_once_with("cuenta_activada")

El fake permite revisar el estado final del usuario, mientras que los mocks verifican interacciones externas importantes.

2.17 Conclusión

Dummy, stub, fake, spy y mock son nombres para distintos roles dentro de una prueba. La elección depende de lo que necesitemos: completar una llamada, devolver datos, conservar estado, registrar interacciones o verificar expectativas.

En el próximo tema prepararemos un proyecto Python específico para practicar mocking con pytest, de modo que podamos ejecutar los ejemplos del curso de forma ordenada.