🔎 Buscar

🧪 TDD en la práctica

Qué es red-green-refactor, el ciclo completo con un ejemplo real paso a paso en Python con pytest, fixtures, parametrize y cuándo no usar TDD.

Wiki / Apuntes📖 Contenido

TDD en la práctica

TDD (Test-Driven Development) es una disciplina de desarrollo, no una técnica de testing. La idea radical: escribes el test antes de la implementación, ves fallar, implementas lo mínimo para que pase y luego refactorizas. El test no es un “extra” al final: es el motor que guía el diseño.

El ciclo es siempre el mismo:

  1. Red: escribes un test que falla (aún no hay código, o falla por otra razón).
  2. Green: escribes el mínimo código para que pase.
  3. Refactor: limpias sin cambiar el comportamiento, con los tests como red de seguridad.

💡 TDD no es “escribir muchos tests”. Es usar los tests para tomar decisiones de diseño mientras escribes código. El resultado secundario (cobertura, regresión) es un bono, no el objetivo.


El ciclo completo, paso a paso

Vamos a construir una calculadora de precios con descuento en Python con pytest. La especificación: una función apply_discount(price, discount_percent) que devuelve el precio con el descuento aplicado, redondeado a 2 decimales, y lanza un error si los datos no son válidos.

Paso 0: preparar el entorno

python -m venv .venv && source .venv/bin/activate
pip install pytest

Estructura:

proyecto/
├── pricing.py
└── test_pricing.py

Paso 1 — RED: escribir el test primero

En test_pricing.py escribimos lo que esperamos que haga la función, aunque no exista:

import pytest
from pricing import apply_discount


def test_precio_con_descuento():
    assert apply_discount(100.0, 20) == 80.0


def test_descuento_entero_y_porcentaje():
    assert apply_discount(50.0, 10) == 45.0


def test_descuento_redondea_a_2_decimales():
    assert apply_discount(99.99, 33) == 66.99


def test_descuento_cero_devuelve_precio_original():
    assert apply_discount(25.0, 0) == 25.0

Ejecutamos y vemos fallar:

pytest
# E   ModuleNotFoundError: No module named 'pricing'
# FAILED — 0 passed, 4 failed

💡 Ver el test fallar por la razón correcta es clave. En RED buscamos que falle porque el comportamiento no existe o está mal, no por un error de configuración del test.

Paso 2 — GREEN: el mínimo para pasar

Creamos pricing.py con la implementación más simple que haga pasar los cuatro tests:

def apply_discount(price, discount_percent):
    return round(price * (1 - discount_percent / 100), 2)
pytest
# 4 passed

Paso 3 — RED de nuevo: el test descubre un caso que falta

Ahora añadimos un test para entradas inválidas y vemos cómo nuestro código no lo maneja:

def test_descuento_fuera_de_rango_lanza_error():
    with pytest.raises(ValueError):
        apply_discount(100.0, 120)   # descuento mayor que 100%


def test_precio_negativo_lanza_error():
    with pytest.raises(ValueError):
        apply_discount(-10.0, 10)
pytest
# 2 failed (no se lanza ValueError)

Paso 4 — GREEN: implementar la validación

def apply_discount(price, discount_percent):
    if price < 0:
        raise ValueError("price no puede ser negativo")
    if not 0 <= discount_percent <= 100:
        raise ValueError("discount_percent debe estar entre 0 y 100")
    return round(price * (1 - discount_percent / 100), 2)
pytest
# 6 passed

Paso 5 — REFACTOR: limpiar sin cambiar comportamiento

Refactorizamos para extraer la validación y usar nombres más claros. Los tests ya existentes nos dicen que no rompimos nada:

def apply_discount(price, discount_percent):
    _validar(price, discount_percent)
    factor = 1 - discount_percent / 100
    return round(price * factor, 2)


def _validar(price, discount_percent):
    if price < 0:
        raise ValueError("price no puede ser negativo")
    if not 0 <= discount_percent <= 100:
        raise ValueError("discount_percent debe estar entre 0 y 100")
pytest
# 6 passed  → refactor seguro

⚠️ En Refactor los tests ya pasan y no deben cambiar de comportamiento. Si al refactorizar un test deja de pasar, o has roto algo o el test era frágil (acoplado a la implementación, no al comportamiento).


Fixtures y parametrize

parametrize: un test, muchos casos

En lugar de escribir seis tests casi iguales, parametrizamos:

import pytest
from pricing import apply_discount


@pytest.mark.parametrize(
    "price, discount, expected",
    [
        (100.0, 20, 80.0),
        (50.0, 10, 45.0),
        (99.99, 33, 66.99),
        (25.0, 0, 25.0),
        (0.0, 50, 0.0),
        (1.0, 100, 0.0),
    ],
)
def test_aplica_descuento(price, discount, expected):
    assert apply_discount(price, discount) == expected


@pytest.mark.parametrize("price, discount", [(-10.0, 10), (100.0, 120), (100.0, -1)])
def test_entrada_invalida_lanza_error(price, discount):
    with pytest.raises(ValueError):
        apply_discount(price, discount)

Cada combinación genera un caso independiente:

pytest -v
# test_aplica_descuento[100-20-80.0] PASSED
# test_aplica_descuento[50-10-45.0]  PASSED
# ... (9 casos)

Fixtures: preparar datos reutilizables

Cuando varios tests comparten la misma preparación (una conexión, un objeto, una BD en memoria), la extraemos a una fixture:

import pytest


class Carrito:
    def __init__(self):
        self.items = []

    def agregar(self, nombre, precio, cantidad=1):
        self.items.append({"nombre": nombre, "precio": precio, "cantidad": cantidad})

    def total(self, descuento=0):
        subtotal = sum(i["precio"] * i["cantidad"] for i in self.items)
        return round(subtotal * (1 - descuento / 100), 2)


@pytest.fixture
def carrito():
    """Devuelve un carrito recién creado con algunos productos."""
    c = Carrito()
    c.agregar("Camiseta", 20.0, 2)
    c.agregar("Gorra", 15.0, 1)
    return c


def test_total_sin_descuento(carrito):
    assert carrito.total() == 55.0


def test_total_con_descuento(carrito):
    assert carrito.total(10) == 49.5

💡 Las fixtures se ejecutan antes de cada test y se limpian después (por defecto con alcance function). Para setups caros (conexiones a BD) usa scope="session" o "module".


Test-first: escribir el test correcto

El orden mental de TDD — qué debería pasar antes de cómo lo implemento — fuerza a pensar en contrato y comportamiento, no en detalles internos:

# 1. Escribo el comportamiento que quiero (red)
def test_sistema_de_reservas_rechaza_solapamiento():
    reservas = SistemaReservas()
    reservas.reservar("sala-a", fecha="2026-09-01", hora="10:00")
    with pytest.raises(ConflictoReserva):
        reservas.reservar("sala-a", fecha="2026-09-01", hora="10:30")

# 2. Después implemento lo mínimo (green)
class ConflictoReserva(Exception):
    pass


class SistemaReservas:
    def __init__(self):
        self._reservas = []

    def reservar(self, sala, fecha, hora):
        for r in self._reservas:
            if r["sala"] == sala and r["fecha"] == fecha and r["hora"] == hora:
                raise ConflictoReserva("la sala ya está reservada")
        self._reservas.append({"sala": sala, "fecha": fecha, "hora": hora})

El test te dice el API público ideal antes de que exista; si el test es incómodo de escribir, probablemente el diseño lo es también.


Tests de integración

TDD puro trabaja con unit tests (una función, sin dependencias externas). Pero una app real tiene BD, HTTP, servicios. Ahí entran los tests de integración, que ejercitan varias capas juntas:

# test_api.py — usa una BD de prueba y el cliente HTTP de la app
def test_crear_usuario_via_api(client, db_session):
    r = client.post("/usuarios", json={"email": "ana@x.com"})
    assert r.status_code == 201
    assert db_session.query(Usuario).filter_by(email="ana@x.com").count() == 1

La diferencia práctica:

Tipo Qué verifica Alcance Velocidad
Unit test Una unidad aislada (fixtures/mocks) Pequeño Rápida
Integration test Varias capas juntas (app + BD + API) Medio Media
E2E Flujo completo por la UI Grande Lenta y frágil

⚠️ No conviertas los unit tests en tests de integración accidental conectando la BD real. Aísla las dependencias externas con mocks/fakes en los tests de unidad, y reserva la infra real para los de integración.


La pirámide de testing

La pirámide (Mike Cohn) dice dónde poner tu esfuerzo:

        /\    E2E (pocos): flujos críticos de la UI
       /  \
      /----\  Integration (algunos): capas juntas
     /------\
    /--------\  Unit (muchos): base de todo, rápidos
   /----------\

Regla práctica: muchos tests de unidad (baratos, rápidos, localizan el fallo), algunos de integración, y pocos E2E (lentos, frágiles). La forma invertida (muchos E2E) es el famoso “ice-cream cone” y es una señal de arquitectura acoplada a la UI.

La estrategia de la pirámide dorada: una capa gruesa de unit tests con contratos estables + una capa de integración que verifica que las piezas encajan + un mínimo E2E para los caminos que valen dinero.


Cuándo NO usar TDD

TDD es una herramienta, no un dogma. Hay situaciones legítimas donde no aporta:

Situación Por qué evitar TDD estricto
Prototipos/exploración La incertidumbre es alta; primero entiendes, luego fijas con tests
One-off / scripts de operación Un script que se ejecuta una vez no justifica su suite
Código de solo integración visual UI muy visual y exploratoria; el valor está en verla
Problema ya resuelto y estable Portar código probado; los tests de regresión ya existen
Refactor de deuda sin tests previos A veces primero hay que estabilizar antes de poder testear

En estos casos, un test de humo o un test de integración en el límite (verificar que el sistema arranca y responde) es más rentable que forzar TDD.


El debate: Beck vs DHH

TDD tiene detractores serios. El más célebre es David Heinemeier Hansson (creador de Rails), que en “TDD is dead. Long live testing” (2014, con Martin Fowler y Kent Beck) argumentaba:

  • DHH: TDD rígido lleva a mockery — tests sobrecargados de mocks que solo verifican tu propia implementación, no el comportamiento real. A medida que crece el equipo y el código, el test-first se vuelve un freno que endurece el diseño.
  • Kent Beck (inventor de TDD): TDD es un estilo, no una religión. “No hago TDD a veces. Aplico la parte que paga: test-first cuando el problema es claro y el costo del fallo alto.”
  • Martin Fowler: la dicotomía es falsa; lo importante es tener una red de seguridad de tests, independientemente de si se escriben antes o después.

Conclusión práctica: adopta test-first como hábito en lógica de negocio y casos con requisitos claros, y sé pragmático (test después, o solo integración) donde TDD estricto no aporte. La prioridad es la cobertura real del comportamiento crítico, no el ritual.

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.