🧪 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.
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:
- Red: escribes un test que falla (aún no hay código, o falla por otra razón).
- Green: escribes el mínimo código para que pase.
- 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
- Test-Driven Development by Example — Kent Beck: el libro fundacional.
- TDD is dead. Long live testing (Beck, Fowler, DHH): el debate completo en texto y vídeo.
- pytest documentation: fixtures, parametrize y buenas prácticas oficiales.
- The Test Pyramid — Martin Fowler: la pirámide explicada con consejos prácticos.
- Refactoring — Martin Fowler: el catálogo de refactorizaciones que da sentido a la fase refactor.