Una prueba puede usar mocks correctamente y aun así ser difícil de leer. Esto suele pasar cuando el armado del escenario ocupa demasiadas líneas, repite muchos datos irrelevantes o mezcla la intención de la prueba con detalles de construcción.
En este tema veremos cómo usar stubs y builders de datos para que las pruebas expresen mejor el comportamiento que verifican.
Supongamos esta función:
def calcular_descuento_pedido(pedido, repositorio_clientes):
cliente = repositorio_clientes.buscar_por_id(pedido["cliente_id"])
if cliente["categoria"] == "vip" and pedido["total"] >= 1000:
return pedido["total"] * 0.20
return 0
Una prueba puede quedar cargada de datos que no importan para el escenario:
def test_descuento_vip():
pedido = {
"id": 10,
"cliente_id": 1,
"fecha": "2026-05-15",
"estado": "pendiente",
"moneda": "ARS",
"items": [],
"total": 1500,
}
cliente = {
"id": 1,
"nombre": "Ana",
"email": "ana@example.com",
"telefono": "123",
"categoria": "vip",
}
repositorio = RepositorioClientesStub(cliente)
descuento = calcular_descuento_pedido(pedido, repositorio)
assert descuento == 300
La intención se pierde entre datos secundarios.
Un builder de datos crea objetos de prueba con valores por defecto y permite cambiar solo lo importante:
def crear_pedido(**cambios):
pedido = {
"id": 10,
"cliente_id": 1,
"fecha": "2026-05-15",
"estado": "pendiente",
"moneda": "ARS",
"items": [],
"total": 500,
}
pedido.update(cambios)
return pedido
Ahora cada prueba puede construir un pedido indicando solo lo relevante.
Otro builder:
def crear_cliente(**cambios):
cliente = {
"id": 1,
"nombre": "Ana",
"email": "ana@example.com",
"telefono": "123",
"categoria": "comun",
}
cliente.update(cambios)
return cliente
El valor por defecto es un cliente común. Si una prueba necesita un cliente VIP, cambia solo categoria.
La prueba anterior queda más clara:
def test_descuento_para_cliente_vip_con_total_suficiente():
pedido = crear_pedido(total=1500)
cliente = crear_cliente(categoria="vip")
repositorio = RepositorioClientesStub(cliente)
descuento = calcular_descuento_pedido(pedido, repositorio)
assert descuento == 300
Ahora se ve el escenario: total suficiente y cliente VIP.
El stub puede ser muy pequeño:
class RepositorioClientesStub:
def __init__(self, cliente):
self.cliente = cliente
def buscar_por_id(self, cliente_id):
return self.cliente
El builder prepara los datos y el stub controla la respuesta del repositorio. Cada uno tiene una responsabilidad clara.
Los valores por defecto deben representar un objeto válido y simple. Así las pruebas solo cambian lo que importa:
def test_cliente_vip_sin_total_suficiente_no_recibe_descuento():
pedido = crear_pedido(total=900)
cliente = crear_cliente(categoria="vip")
repositorio = RepositorioClientesStub(cliente)
descuento = calcular_descuento_pedido(pedido, repositorio)
assert descuento == 0
La prueba no menciona email, teléfono, estado ni moneda porque no son relevantes.
Un builder debe simplificar, no ocultar reglas importantes. Si el builder toma muchas decisiones invisibles, la prueba puede volverse engañosa.
Si el proyecto usa objetos en lugar de diccionarios, podemos usar dataclasses:
from dataclasses import dataclass
@dataclass
class Cliente:
id: int
nombre: str
categoria: str
def crear_cliente(**cambios):
datos = {
"id": 1,
"nombre": "Ana",
"categoria": "comun",
}
datos.update(cambios)
return Cliente(**datos)
El patrón es el mismo: valores por defecto y cambios explícitos.
Podemos crear helpers para estructuras más anidadas:
def crear_item(**cambios):
item = {
"producto": "Teclado",
"precio": 100,
"cantidad": 1,
}
item.update(cambios)
return item
def crear_pedido(**cambios):
pedido = {
"id": 10,
"cliente_id": 1,
"items": [crear_item()],
"total": 100,
}
pedido.update(cambios)
return pedido
Una prueba puede cambiar solo un item o el total.
Si varios archivos de prueba usan los mismos builders, se pueden mover a un módulo de soporte o a fixtures:
import pytest
@pytest.fixture
def cliente_vip():
return crear_cliente(categoria="vip")
@pytest.fixture
def pedido_grande():
return crear_pedido(total=1500)
Úsalas cuando el nombre de la fixture aporte claridad al escenario.
Una fixture puede ocultar demasiado. Si una prueba depende de muchos datos internos de una fixture, quien lee la prueba debe saltar constantemente a otro archivo para entenderla.
En esos casos, puede ser mejor llamar al builder directamente en la prueba:
pedido = crear_pedido(total=1500)
cliente = crear_cliente(categoria="vip")
La intención queda visible.
También podemos tener funciones que creen stubs configurados:
def repositorio_con_cliente(cliente):
return RepositorioClientesStub(cliente)
def test_descuento_vip():
repositorio = repositorio_con_cliente(crear_cliente(categoria="vip"))
pedido = crear_pedido(total=1500)
assert calcular_descuento_pedido(pedido, repositorio) == 300
Esto puede mejorar la lectura si el nombre del helper expresa el escenario.
Si un mock devuelve estructuras grandes configuradas línea por línea, considera mover esos datos a builders:
repositorio.buscar_por_id.return_value = crear_cliente(categoria="vip")
Esto suele ser más legible que escribir todo el diccionario dentro de la configuración del mock.
Los builders no reemplazan buenos nombres de prueba. El nombre debe decir qué comportamiento se verifica:
def test_cliente_vip_con_total_suficiente_recibe_20_por_ciento():
...
Luego el builder debe reforzar esa intención mostrando los datos clave.
Refactoriza esta prueba usando builders:
def test_usuario_admin_puede_publicar():
usuario = {
"id": 1,
"nombre": "Ana",
"email": "ana@example.com",
"telefono": "123",
"rol": "admin",
"activo": True,
}
repositorio = RepositorioUsuariosStub(usuario)
assert puede_publicar(1, repositorio) is True
Crea un builder de usuario que permita cambiar solo rol y activo.
Una solución:
def crear_usuario(**cambios):
usuario = {
"id": 1,
"nombre": "Ana",
"email": "ana@example.com",
"telefono": "123",
"rol": "lector",
"activo": True,
}
usuario.update(cambios)
return usuario
class RepositorioUsuariosStub:
def __init__(self, usuario):
self.usuario = usuario
def buscar_por_id(self, usuario_id):
return self.usuario
def test_usuario_admin_puede_publicar():
usuario = crear_usuario(rol="admin")
repositorio = RepositorioUsuariosStub(usuario)
assert puede_publicar(1, repositorio) is True
def test_usuario_inactivo_no_puede_publicar():
usuario = crear_usuario(rol="admin", activo=False)
repositorio = RepositorioUsuariosStub(usuario)
assert puede_publicar(1, repositorio) is False
Ahora cada prueba muestra solo el dato que define el escenario.
Los builders de datos y stubs simples ayudan a escribir pruebas más legibles. Separan el armado repetitivo del escenario y permiten que cada prueba muestre con claridad qué comportamiento quiere verificar.
En el próximo tema veremos cuándo no conviene usar mocks y cómo reconocer pruebas demasiado acopladas.