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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
Los mocks son adecuados cuando queremos verificar una interacción importante:
En esos casos, la interacción es parte del comportamiento.
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.
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.
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.
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.
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.
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.
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.
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.