26. Diseñar pruebas legibles usando stubs y builders de datos

26.1 Objetivo del tema

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.

Objetivo práctico: reducir ruido en las pruebas y hacer que cada escenario muestre solo los datos importantes.

26.2 Problema: demasiados datos en la prueba

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.

26.3 Builder como función

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.

26.4 Builder para cliente

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.

26.5 Prueba más legible con builders

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.

26.6 Stub configurable simple

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.

26.7 Builder con datos por defecto útiles

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.

26.8 Evitar builders demasiado mágicos

Un builder debe simplificar, no ocultar reglas importantes. Si el builder toma muchas decisiones invisibles, la prueba puede volverse engañosa.

Un buen builder hace obvio el dato importante del escenario y oculta solo el ruido accidental.

26.9 Builders con dataclasses

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.

26.10 Builder de pedidos con items

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.

26.11 Fixtures para builders

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.

26.12 Evitar fixtures opacas

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.

26.13 Builder de stubs

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.

26.14 Builders frente a mocks complejos

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.

26.15 Nombres de pruebas y builders

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.

26.16 Señales de que necesitas un builder

  • Varias pruebas repiten el mismo diccionario grande.
  • Cada prueba cambia solo uno o dos campos.
  • El dato importante queda perdido entre muchos campos irrelevantes.
  • Actualizar la estructura de datos obliga a modificar muchas pruebas.
  • Los mocks tienen configuraciones extensas solo para devolver datos.

26.17 Ejercicio práctico

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.

26.18 Solución posible del ejercicio

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.

26.19 Conclusión

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.