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.
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.
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 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á.
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.
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.
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.
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.
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.
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.
Una diferencia práctica es la pregunta que responde cada uno:
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.
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)
La siguiente guía ayuda a elegir:
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.
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.
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.
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.