El TDD provoca dos reacciones extremas. La primera: “ya escribo tests, entonces hago TDD”. La segunda: “escribir los tests antes solo invierte el esfuerzo sin ganar mucho”. Ambas pasan por alto lo que realmente es el TDD. No es una cuestión de cobertura ni una simple inversión de orden. Es una disciplina de diseño que obliga a explicitar una intención antes de escribir el código que la satisface.
Este artículo abre una serie sobre TDD. Antes de comparar las escuelas (Chicago, Londres, ATDD doble bucle, TDD estricto), hay que asentar el tronco común: el ciclo Rojo-Verde-Refactor, la práctica de los baby steps y las propiedades FIRST que definen un test que merece ese nombre. Los artículos siguientes partirán de esta base para hacer los compromisos tangibles, con ejemplos concretos.
El ciclo Rojo-Verde-Refactor
Kent Beck lo formuló en tres fases, y el orden no es negociable.
- Rojo: escribir un test que falla porque el comportamiento que describe aún no existe.
- Verde: escribir el mínimo de código de producción que hace pasar el test.
- Refactor: mejorar la estructura del código y de los tests, sin añadir funcionalidad ni cambiar el comportamiento.
Tres minutos por bucle en el caso ideal, una decena cuando el test obliga a pensar en una frontera. Lo que se llama “hacer TDD” no es más que este bucle, repetido decenas de veces al día.
La trampa clásica consiste en fusionar Verde y Refactor en un solo paso, diciéndose: “voy a escribir directamente el código limpio”. Es un retorno al diseño anticipado disfrazado. El ciclo pierde su propiedad más útil, que es poder avanzar sabiendo que se puede romper algo en cualquier momento y detectarlo en segundos.
Por qué Rojo primero
El orden “test y luego código” plantea una pregunta sencilla antes de tocar la implementación: ¿cuál es la intención exacta de este comportamiento?
def test_factura_con_descuento_superior_al_monto_se_vuelve_gratis():
factura = Factura(monto_cents=5000)
factura.aplicar_descuento(descuento_cents=6000)
assert factura.total_cents == 0
Antes de este test, la pregunta “¿qué hacer si el descuento supera el monto?” no tenía respuesta explícita. El test la fija. El código de producción ya no tendrá que inventar una respuesta, deberá satisfacer esa.
El otro beneficio de Rojo es que valida el test en sí mismo. Un test que pasa de inmediato sin implementación es sospechoso: quizá esté probando un comportamiento ya existente, o su aserción es demasiado débil para distinguir el éxito del fallo. Ver el test fallar por la razón correcta es comprobar que ese test será útil mañana para detectar una regresión.
Verde: el código mínimo que pasa
Es el paso más contraintuitivo. La instrucción es escribir el código más ingenuo posible que haga pasar el test, aunque ese código sea manifiestamente insuficiente para el uso real.
class Factura:
def __init__(self, monto_cents):
self.monto_cents = monto_cents
self.total_cents = monto_cents
def aplicar_descuento(self, descuento_cents):
self.total_cents = 0 # suficiente para hacer pasar el test actual
Esta versión hará torcer el gesto. No gestiona el caso en que el descuento es inferior al monto. Ese es exactamente el objetivo. El siguiente test vendrá a forzar la extensión del código:
def test_factura_con_descuento_parcial_resta_del_monto():
factura = Factura(monto_cents=5000)
factura.aplicar_descuento(descuento_cents=2000)
assert factura.total_cents == 3000
Ahora self.total_cents = 0 ya no basta. El código debe evolucionar para satisfacer los dos tests. Con cada test añadido, el código crece justo lo necesario. Esta progresión por acreción se llama triangulación: se deduce la implementación general a partir de una serie de ejemplos concretos, sin sobreanticipar nunca.
Lo opuesto, escribir de entrada el código completo que anticipa todos los casos, es precisamente lo que TDD intenta evitar. La sobreingeniería especulativa es la primera causa de abstracciones inútiles en un proyecto.
Refactor: el paso que todo el mundo se salta
Una vez que los tests están verdes, se tiene derecho a tocar el código sin cambiar su comportamiento. Es el único momento en el que se puede renombrar, extraer una función, eliminar un duplicado, sin riesgo. Los tests existentes hacen de red de seguridad.
También es el paso más descuidado. El reflejo es: “los tests pasan, paso a la siguiente funcionalidad”. A corto plazo, se avanza. A medio plazo, el código acumula deuda porque un código escrito solo para hacer pasar un test no tiene por qué ser legible por defecto. Refactor es el paso que convierte un código “que funciona” en un código “que se puede hacer evolucionar”.
Lo que se refactoriza en esta fase:
- los nombres que no dicen nada (
tmp,data,do_stuff), - la duplicación entre dos pasos consecutivos de Verde,
- las condiciones anidadas que la triangulación ha hecho aparecer,
- los propios tests (el código de test es código, merece el mismo rigor).
Lo que no se hace durante Refactor: añadir una abstracción “por si acaso”, introducir un patrón porque es elegante, generalizar a un caso que el test no cubre. La regla es estricta. Ninguna funcionalidad cambia durante Refactor. Si hay que añadir comportamiento, se vuelve a Rojo.
Baby steps: el tamaño del paso importa
Los bucles RVR deben ser cortos. No por dogma, sino porque un ciclo largo mezcla varias decisiones y ahoga la señal en caso de error. Si un test se queda en rojo durante veinte minutos, ya se ha perdido la propiedad más valiosa del TDD: saber de inmediato qué cambio ha roto qué.
La práctica de los baby steps consiste en trocear el avance en incrementos minúsculos. Cada test apunta a una sola micro-decisión. Cada modificación del código de producción es el mínimo necesario para hacer pasar el test actual. Se puede literalmente avanzar de pocas líneas en pocas líneas.
La ventaja es doble. Por un lado, el coste de volver atrás es trivial: deshacer treinta segundos de trabajo en lugar de treinta minutos. Por otro, la presión cognitiva baja, porque solo se mantiene un test en mente a la vez, no una “funcionalidad” entera.
La objeción habitual es que se escriben “demasiados” tests. Es confundir cantidad y calidad. Tres tests que aíslan tres casos distintos valen más que un único test cajón de sastre que comprueba varias cosas y ya no se sabe interpretar cuando se rompe.
FIRST: las propiedades de un test que merece ese nombre
No todos los tests son iguales. Robert C. Martin resumió en cinco letras lo que distingue un test explotable de uno decorativo: FIRST.
Fast. Un test debe ejecutarse en milisegundos. Si la suite tarda cinco minutos, nadie la lanza entre dos modificaciones, y la red de seguridad desaparece. Tocar la base de datos, la red o el sistema de ficheros es algo a evitar en la mayoría de los tests unitarios. Los tests de integración existen para esos casos, pero son una minoría.
Independent. Ningún test debe depender del orden de ejecución de otro. Si test_b solo pasa porque test_a insertó una fila en la base justo antes, el aislamiento está roto. Lanzar un solo test al azar debe producir el mismo resultado que lanzar la suite entera.
Repeatable. El test produce el mismo resultado en cada ejecución, en cualquier máquina, en cualquier orden. Un test que falla una vez de cada diez por un datetime.now() mal controlado o un asyncio.sleep() no es un test, es una trampa que se acaba ignorando y luego desactivando.
Self-verifying. El test sabe decir por sí solo si ha pasado o fallado. Sin lectura humana de un log para interpretar el resultado. Una aserción clara, que señala la verdadera causa del fallo, no un print que hay que descifrar.
Precise. El test es preciso en su intención. Un test, un comportamiento. Si una función de test contiene cinco aserciones heterogéneas, su fallo no dice qué se ha roto. La precisión también se gana en el nombre: test_factura_con_descuento_superior_al_monto_se_vuelve_gratis es un pliego de especificaciones en una línea. test_factura_descuento no dice nada.
Estas cinco propiedades son los criterios que se debe poder marcar para cada test que se añade. Un test que viola alguna no se convierte en un test “a mejorar más tarde”. Se convierte en un test que degrada toda la suite, porque introduce ruido que los otros tests tienen que compensar.
Lo que TDD no es
Algunas confusiones extendidas que conviene aclarar antes de cerrar este tronco.
TDD no es una métrica de cobertura. Se puede tener 100% de cobertura sin haber hecho TDD, y se puede hacer TDD sin vigilar la cobertura. La cobertura mide las líneas recorridas, no la pertinencia de las aserciones. Un test que ejecuta código sin comprobar nada cuenta para la cobertura pero no protege de nada.
TDD no es una garantía contra los bugs. Un test solo puede detectar los comportamientos que describe. Los bugs vienen a menudo de comportamientos en los que nadie pensó probar, no de los que sí se probaron. El TDD reduce ciertas clases de errores (regresiones, desalineación entre intención e implementación) y no toca otras (mal diseño de producto, mala comprensión del dominio).
TDD no es más lento. Esta creencia viene de un sesgo contable: se ve el tiempo dedicado a escribir los tests, no se ve el tiempo ahorrado en depuración, revisiones de PR y regresiones evitadas. En un horizonte de algunas semanas, el TDD es neutro o más rápido en la mayoría de los proyectos no triviales. En un sprint aislado, puede parecer más lento, sobre todo en fase de aprendizaje.
TDD no exime de pensar el diseño. El ciclo Rojo-Verde-Refactor no inventa la arquitectura por ti. Obliga a formular una intención antes de implementarla, lo cual ayuda, pero no sustituye ni a la reflexión sobre las fronteras del sistema ni a la elección de buenas abstracciones. Ahí es precisamente donde intervienen las escuelas TDD, que se distinguen por dónde hacen nacer esas decisiones de diseño.
Y ahora: las escuelas
El ciclo, los baby steps y FIRST son comunes a toda práctica TDD. Las diferencias aparecen cuando se pregunta: ¿por dónde se empieza? ¿El corazón del dominio, partiendo de las entidades más simples (inside-out, escuela de Chicago)? ¿La frontera del usuario, desde el exterior hacia el interior (outside-in, escuela de Londres)? ¿Con un test de aceptación como guardarraíl (ATDD doble bucle)? ¿O escribiendo el código de producción directamente en el cuerpo del test (TDD estricto)?
Cada escuela aporta una respuesta diferente a esas preguntas, y cada respuesta tiene consecuencias sobre el diseño final, sobre el coste de mantenimiento de los tests, y sobre los tipos de bugs que se cazan. Los próximos artículos de la serie las compararán una a una, sobre el mismo ejemplo, para hacer los compromisos tangibles más que teóricos.
