Hasta ahora la secuencia fue siempre: escribir código, luego protegerlo con tests. El Desarrollo Guiado por Pruebas (TDD, Test-Driven Development) invierte la flecha: el test se escribe antes de que exista el código que lo hará pasar. Suena a truco de fakir, pero es una disciplina de diseño con reglas precisas y beneficios medibles — y también con límites que conviene conocer sin fanatismo. La practicarás con una pieza nueva de Papyrus que Ana lleva semanas pidiendo: cupones de descuento para la campaña de verano. Nacerá entera con TDD, test a test.
Contenido
- El ciclo Rojo-Verde-Refactor
- Las reglas del juego
- Kata Papyrus:
aplicar_cupon, iteración a iteración - Refactor con red de seguridad
- TDD como herramienta de diseño
- Beneficios y críticas honestas
El ciclo Rojo-Verde-Refactor
TDD es un ciclo de tres fases, corto (minutos, no horas), que se repite hasta terminar la funcionalidad:
graph LR
R["ROJO<br/>Escribe un test que falla<br/>(define QUÉ quieres)"] --> V["VERDE<br/>Escribe el código MÍNIMO<br/>que lo hace pasar"]
V --> F["REFACTOR<br/>Mejora el código<br/>sin cambiar comportamiento"]
F --> R
| Fase | Qué haces | Qué está prohibido |
|---|---|---|
| Rojo | Escribes un test pequeño para el siguiente comportamiento y lo ves fallar | Escribir dos tests a la vez; saltarte la ejecución "porque ya sé que falla" |
| Verde | Escribes lo mínimo — aunque sea burdo — para que toda la suite pase | Añadir funcionalidad "ya que estoy"; perseguir elegancia |
| Refactor | Limpias duplicación y mejoras nombres, con la suite en verde como red | Cambiar el comportamiento; refactorizar con tests en rojo |
Ver el test fallar primero no es un ritual vacío: es la prueba de que el test prueba algo. Un test que nace en verde puede estar comprobando nada (recuerda el método sin prefijo test_ de 09-02, que "pasaba" porque no existía).
Las reglas del juego
Las tres reglas clásicas (formuladas por Robert C. Martin), en versión práctica:
- No escribas código de producción salvo para hacer pasar un test que está en rojo.
- No escribas más test del necesario para fallar (un error de compilación/importación ya cuenta como fallo).
- No escribas más código de producción del necesario para pasar el test en rojo.
Y una actitud transversal: baby steps — pasos de bebé. Cada ciclo añade un comportamiento diminuto. La tentación de "resolverlo todo de una vez" es exactamente lo que TDD viene a domar: pasos pequeños significan que cuando algo se rompe, la causa está en las últimas diez líneas.
Kata Papyrus: aplicar_cupon, iteración a iteración
El encargo de Ana: "Quiero cupones sobre el importe final de una venta. El PAPYRUS10 descuenta un 10 %, el SOCIO5 un 5 %. Un código inválido debe dar un error claro, los cupones no son acumulables, y los importes siempre a 2 decimales." Una kata es un ejercicio deliberado para practicar la mecánica; esta será real: el código acabará en papyrus/cupones.py.
Iteración 1 — Rojo: el primer test define la API
Aún no existe ni el módulo. Empezamos escribiendo cómo nos gustaría usarlo:
# tests/test_cupones.py
import pytest
from papyrus.cupones import aplicar_cupon
def test_papyrus10_descuenta_diez_por_ciento():
# Hamlet x2 con IVA son 20.70 (dato canónico); -10% = 18.63
assert aplicar_cupon(20.70, "PAPYRUS10") == pytest.approx(18.63)pytest → rojo: ModuleNotFoundError: No module named 'papyrus.cupones'. Perfecto: la regla 2 dice que un fallo de importación ya es un test en rojo. Sin darnos cuenta, hemos tomado tres decisiones de diseño: la función se llama aplicar_cupon, recibe (importe, codigo) en ese orden y devuelve el importe (no lo imprime, no muta nada).
Iteración 1 — Verde: el código mínimo (aunque duela)
¿Devolver una constante? Sí. Es el paso mínimo legal, y se llama fake it (fíngelo). Parece absurdo, pero disciplina algo valioso: el código solo se generaliza cuando un test lo exige. pytest → verde. Nada que refactorizar aún.
Iteración 2 — Rojo: triangular para forzar la fórmula
Obligamos a generalizar con un segundo ejemplo (esto se llama triangulación):
def test_papyrus10_con_otro_importe():
assert aplicar_cupon(100.00, "PAPYRUS10") == pytest.approx(90.00)Rojo: 18.63 != 90.0. La constante ya no cuela; ahora sí, la fórmula es el mínimo:
Verde (los dos tests pasan — la suite entera se ejecuta siempre).
Iteración 3 — Rojo: el segundo cupón
def test_socio5_descuenta_cinco_por_ciento():
# Una Odisea sin carné (13.00) con SOCIO5 → 12.35:
# el mismo precio que pagaría un socio, porque ×0.95×1.04 conmuta
assert aplicar_cupon(13.00, "SOCIO5") == pytest.approx(12.35)Rojo: devolvió 11.70 (aplicó el 10 %). Mínimo para verde: distinguir códigos.
def aplicar_cupon(importe: float, codigo: str) -> float:
if codigo == "SOCIO5":
return round(importe * 0.95, 2)
return round(importe * 0.90, 2)Verde. Huele mal (¿cualquier código desconocido descuenta un 10 %?), pero ese olor es el siguiente test, no un arreglo furtivo: la regla 3 prohíbe adelantarse.
Iteración 4 — Rojo: código inválido → error claro
El error merece su propia excepción en la jerarquía de M7:
# En papyrus/errores.py, junto a sus hermanas:
class CuponInvalidoError(ErrorPapyrus):
def __init__(self, codigo: str):
self.codigo = codigo
super().__init__(f"Cupón no válido: {codigo!r}")from papyrus.errores import CuponInvalidoError
def test_codigo_invalido_lanza_error():
with pytest.raises(CuponInvalidoError, match="VERANO99"):
aplicar_cupon(20.70, "VERANO99")Rojo (devolvió 18.63 en vez de fallar). Verde:
from papyrus.errores import CuponInvalidoError
def aplicar_cupon(importe: float, codigo: str) -> float:
if codigo == "PAPYRUS10":
return round(importe * 0.90, 2)
if codigo == "SOCIO5":
return round(importe * 0.95, 2)
raise CuponInvalidoError(codigo)Iteración 5 — Rojo: no acumulables
¿Qué significa "no acumulables" en esta API? Nuestra decisión de diseño: la función acepta un código; cualquier intento de combinar ("PAPYRUS10+SOCIO5") no es un código válido y debe rechazarse:
def test_los_cupones_no_se_acumulan():
with pytest.raises(CuponInvalidoError):
aplicar_cupon(20.70, "PAPYRUS10+SOCIO5")pytest → ¡ya está en verde! El código actual rechaza cualquier código desconocido. ¿Test inútil? No: los requisitos de Ana ahora están escritos. Si mañana alguien "mejora" la función para trocear códigos por +, este test se pondrá rojo y defenderá la regla de negocio. Un test que nace verde exige una comprobación extra: rómpelo a mano un segundo (cambia el importe esperado) para verificar que ejecuta, y restáuralo.
Iteración 6 — Rojo: el redondeo como contrato
def test_redondea_a_dos_decimales():
# 9.99 - 10% = 8.991 → debe salir 8.99, nunca 8.991
assert aplicar_cupon(9.99, "PAPYRUS10") == 8.99Verde a la primera (el round ya estaba), y de nuevo lo fijamos como contrato: si un refactor pierde el redondeo, hay red.
Refactor con red de seguridad
Seis tests en verde. Ahora — y solo ahora — embellecemos. La cascada de if duplica el patrón "porcentaje + round"; un diccionario lo expresa mejor (M4 al rescate):
# papyrus/cupones.py — versión final tras el refactor
from papyrus.errores import CuponInvalidoError
CUPONES: dict[str, float] = {
"PAPYRUS10": 0.10,
"SOCIO5": 0.05,
}
def aplicar_cupon(importe: float, codigo: str) -> float:
"""Aplica un cupón al importe final y devuelve el resultado a 2 decimales.
Lanza CuponInvalidoError si el código no existe. No acumulable.
"""
if codigo not in CUPONES:
raise CuponInvalidoError(codigo)
return round(importe * (1 - CUPONES[codigo]), 2)pytest → los seis tests siguen en verde. Esa es la sensación que define TDD: reescribimos la función entera sin miedo, porque la suite garantiza que el comportamiento no cambió. Añadir el cupón de Navidad será una línea en el diccionario... precedida, por supuesto, de su test.
TDD como herramienta de diseño
Fíjate en lo que pasó en la iteración 1: al escribir primero la llamada, diseñamos la API desde el punto de vista de quien la usa. La firma aplicar_cupon(importe: float, codigo: str) -> float salió del uso, no de un diagrama. TDD empuja sistemáticamente hacia funciones pequeñas, sin efectos ocultos y fáciles de llamar — porque lo difícil de probar se siente inmediatamente en el test. Si el test necesita preparar medio mundo, el diseño está gritando que la función depende de demasiado (la misma señal que anticipamos con unittest.mock en 09-03). El test rojo es, antes que verificación, una especificación ejecutable.
Beneficios y críticas honestas
| TDD compensa cuando... | TDD estorba cuando... |
|---|---|
| La lógica de negocio tiene reglas claras (precios, cupones, stock) | Estás explorando y aún no sabes qué quieres (prototipos, notebooks) |
| El coste de un bug es alto (dinero, datos) | El código es descartable o trivial (un script de una tarde) |
| Vas a mantener el código durante años | La API depende de un sistema externo que aún no entiendes |
| Quieres refactorizar sin miedo | Probar requiere infraestructura pesada que aún no dominas (GUI, hardware) |
Beneficios reales: cobertura que nace con el código (no como tarea aplazable), diseño orientado al uso, documentación ejecutable y refactor sin miedo. Críticas legítimas: los tests son código que también se mantiene (duplican el coste de cambiar una regla de negocio a propósito); TDD mal entendido produce tests acoplados a la implementación que se rompen con cada refactor (prueba comportamiento, no interioridades); y la disciplina estricta puede ser lenta en fases exploratorias. La postura madura no es religiosa: TDD para la lógica de negocio con reglas claras — como los precios de Papyrus —, tests-después para lo exploratorio, y siempre, siempre, un test de regresión cuando cace un bug (lo verás en la próxima lección).
Errores Comunes y Consejos
- No ejecutar el test en rojo antes de codificar. Es el error número uno: un test mal escrito puede nacer verde y no proteger nada. Rojo primero, siempre visto con tus ojos.
- Escribir cinco tests de golpe y luego el código. Eso es "test-first por lotes", no TDD: pierdes el diálogo test↔diseño y los baby steps. Un test, un verde, cada vez.
- Saltarse el refactor. Verde no es la meta, es la mitad. Si acumulas verdes sin limpiar, el código degenera y acabarás culpando a TDD de tu deuda técnica.
- Probar la implementación en vez del comportamiento. Un test que comprueba "usa un diccionario llamado CUPONES" se rompe con cada refactor legítimo. Nuestros tests solo miran entradas y salidas: por eso el refactor de la cascada de
ifal diccionario no tocó ni una línea de test. - Perseguir el 100 % de cobertura como fin. La cobertura mide qué líneas se ejecutan, no qué contratos se verifican. Seis tests con intención valen más que veinte tautológicos.
- Consejo: mantén el ciclo corto. Si llevas veinte minutos en rojo, el paso era demasiado grande: borra, parte el problema y vuelve con un test más pequeño.
Ejercicios
Ejercicio 1
Ana añade el cupón de campaña "VERANO15" (−15 %). Hazlo con TDD estricto: escribe el test (usa aplicar_cupon(20.00, "VERANO15"), calcula tú el esperado), míralo fallar, haz el cambio mínimo y confirma verde. ¿Cuántas líneas de producción tocaste?
Ejercicio 2
Nuevo requisito: un importe negativo no tiene sentido y debe lanzar ValueError (no CuponInvalidoError: el problema es el importe, no el cupón — coherente con el criterio de vender en M7). Ciclo completo: test rojo → mínimo → verde.
Ejercicio 3
Júlia tecleó "papyrus10" en minúsculas y recibió un error. Ana decide que los cupones deben ser insensibles a mayúsculas. Escribe el test rojo, hazlo pasar con el cambio mínimo y responde: ¿algún test anterior se puso rojo con tu cambio? ¿Por qué es importante ejecutar la suite entera en cada verde?
Soluciones
Ejercicio 1 — esperado: 20.00 × 0.85 = 17.00.
def test_verano15_descuenta_quince_por_ciento():
assert aplicar_cupon(20.00, "VERANO15") == pytest.approx(17.00)Rojo (CuponInvalidoError). Verde con una línea en producción: añadir "VERANO15": 0.15, al diccionario CUPONES. Ese es el dividendo del refactor de la iteración final: los datos y la lógica quedaron separados.
Ejercicio 2
def test_importe_negativo_lanza_valueerror():
with pytest.raises(ValueError):
aplicar_cupon(-5.00, "PAPYRUS10")Rojo (devuelve -4.5). Mínimo para verde, al principio de la función:
Ejercicio 3
def test_cupon_en_minusculas_es_valido():
assert aplicar_cupon(100.00, "papyrus10") == pytest.approx(90.00)Rojo. Cambio mínimo: normalizar al entrar — codigo = codigo.upper() como primera línea (o codigo.strip().upper() si quieres tolerar espacios; eso pediría su propio test). Ningún test anterior se rompe: "PAPYRUS10".upper() sigue siendo "PAPYRUS10" y "PAPYRUS10+SOCIO5" sigue sin existir. Pero solo lo sabes porque ejecutaste la suite entera: cada verde es una afirmación sobre todos los contratos a la vez, no solo sobre el último. Ese hábito — suite completa en cada ciclo — es lo que convierte seis tests en una red y no en seis cuerdas sueltas.
Conclusión
Papyrus tiene una funcionalidad nueva — papyrus/cupones.py con aplicar_cupon(importe, codigo) y su CuponInvalidoError en la jerarquía de M7 — que no existió ni un minuto sin tests: nació rojo-verde-refactor, con la API diseñada desde el uso, triangulación para forzar la generalización y un refactor final sin miedo gracias a la red de seis tests. También tienes el mapa honesto: TDD brilla en lógica de negocio con reglas claras y estorba en la exploración; úsalo como herramienta, no como religión. Pero seamos realistas: por muchas pruebas que escribas, los bugs seguirán llegando — en código heredado, en el caso límite que nadie imaginó, en producción un viernes. La siguiente lección trata de eso: qué hacer cuando algo ya está roto. Reproducir, aislar, corregir y — cerrando el círculo con esta lección — proteger lo corregido con un test de regresión. Es hora de aprender a depurar con método.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
