Un test escrito después del código tiende a comprobar lo que el código hace, no lo que debía hacer. El desarrollo guiado por pruebas (TDD) resuelve esto invirtiendo el orden: escribes el test, lo ves fallar, y luego escribes el código mínimo que lo hace pasar. El test existe antes que el código que examina, así que no puede limitarse a confirmar en silencio lo que ese código termine devolviendo. Kent Beck formalizó este ciclo como red, green, refactor en su libro de 2002 Test-Driven Development: By Example. El nombre se quedó.
Qué hace realmente el ciclo red-green-refactor
Cada vuelta del ciclo tiene tres pasos, y saltarte uno lo convierte en otra cosa.
Red. Escribe un test para un comportamiento que todavía no existe y ejecútalo. Tiene que fallar. Si pasa a la primera, o el comportamiento ya está ahí, o el test no comprueba nada real. Una ejecución en rojo demuestra que el test puede detectar el bug que debe detectar.
Green. Escribe la mínima cantidad de código de producción que hace pasar el test. No el mejor código. El código mínimo. Devolver un valor fijo a mano es un paso green legítimo, siempre que sepas que eso es lo que has hecho y no te quedes ahí.
Refactor. Con el test en verde, limpia lo que acabas de escribir: elimina la duplicación, o extrae una función si está justificado. El test tiene que seguir en verde durante todo el proceso, porque así sabes que el comportamiento no ha cambiado, solo la forma del código.
Luego eliges el siguiente comportamiento y vuelves a empezar. Una iteración lleva minutos: una rama if, un caso límite, nunca una funcionalidad entera.
Un ejemplo de TDD en Python: la kata FizzBuzz
FizzBuzz es lo bastante pequeña como para que veas el ritmo en lugar del problema. Dado un número, devuelve "Fizz" si es divisible por 3, "Buzz" si es divisible por 5, "FizzBuzz" si es divisible por ambos, y el número tal cual en cualquier otro caso.
Empieza por el caso que no es ninguno de esos:
| |
| |
Rojo, y no has tenido que fabricar el fallo a propósito: fizzbuzz todavía no existe, así que pytest ni siquiera puede importar el módulo de test. Sigue siendo un rojo válido: la solución es la misma en ambos casos. Lo mínimo que lo hace pasar:
| |
| |
Verde. Siguiente comportamiento: los múltiplos de 3.
| |
Falla (assert '3' == 'Fizz'), y la solución mínima es una sola rama:
| |
Los múltiplos de 5 siguen la misma forma, así que añade el test y el elif correspondiente. Luego escribe el caso que pilla el descuido de los dos primeros pasos: un número divisible por ambos.
| |
| |
15 % 3 == 0 se comprueba primero, así que la función devuelve "Fizz" antes de llegar a mirar 15 % 5. Aquí el orden importa. La solución es una rama para 15 que se ejecuta antes que las otras dos:
| |
| |
Verde otra vez, y ahora el archivo de tests tiene algo que merece la pena refactorizar: cuatro funciones casi idénticas. Unirlas en un único test parametrizado cuenta como un refactor tanto como cualquier cosa que harías en fizzbuzz.py, siempre que el verde aguante todo el tiempo.
| |
El bug del orden solo salió a la luz porque el test de 15 existía antes de la solución. Escribe el código primero y párate en “3 da Fizz, 5 da Buzz, parece terminado”, y fizzbuzz(15) devuelve "Fizz" sin nada en la suite que lo delate.
Por qué el código escrito test-first acaba poco acoplado
El TDD cambia el diseño antes de ejecutar ningún test, porque tienes que escribir el código para que el test pueda llamarlo. Toma una clase que se construye su propio repositorio contra la base de datos por dentro, justo el patrón con el que abre Qué Es la Dependency Injection. No puedes pasarle un test double, porque nada en su firma te deja sustituirlo. Escribir el test primero empuja esa dependencia a un parámetro o a un argumento del constructor antes de que el código de producción exista para resistirse. No añades el acoplamiento débil después, como un extra. Sin eso, el test que estás a punto de escribir no se ejecuta.
La misma presión empuja a las funciones a hacer una sola cosa. Si un test tiene que preparar tres piezas de estado sin relación para comprobar un comportamiento, la función bajo test hace tres cosas sin relación. Divídela, y el test se vuelve más corto.
Cuándo no usar TDD
El ciclo necesita unas condiciones: sabes el comportamiento que quieres antes de escribirlo, los tests se vuelven a ejecutar en segundos, y el código tiene un punto donde meter un test double. Pierde cualquiera de ellas y el ciclo deja de compensar. Incluso cuando se cumplen todas, tiene un punto ciego.
Todavía no sabes qué forma tiene la cosa. Si estás explorando una API que no conoces o haciendo un spike para ver si un enfoque es viable, escribe primero código desechable. Aprende cuál es la abstracción correcta y luego escribe los tests contra la versión que piensas quedarte. Los tests para código que vas a borrar son esfuerzo gastado dos veces.
El ciclo de feedback es lento. El TDD implica volver a ejecutar los tests tras cada cambio pequeño. Una suite que toca una base de datos real o la red en cada ejecución convierte cada una de esas repeticiones en una espera. Pruebas Unitarias vs de Integración vs End-to-End explica por qué la velocidad a nivel unitario viene de sustituir esas dependencias. Si los únicos tests que puedes escribir para un trozo de código son tests de integración lentos, hacer TDD con él significa esperar, y la gente deja de hacer las cosas que la hacen esperar.
Todavía no hay una costura. El código legacy, con constructores que llegan hasta infraestructura real y funciones que se llaman directamente entre sí, no se puede hacer TDD desde donde está. No hay dónde meter un double. Qué Es el Spaghetti Code explica cómo entrar: un characterization test que fija primero el comportamiento existente, escrito contra el código tal cual es. Una vez que esa red existe, red-green-refactor funciona para el siguiente cambio.
El mocking excesivo deja pasar un mal diseño de todos modos. Un test que hace mock de cada colaborador puede ponerse en verde sobre una función que por dentro es un desastre, siempre que sus llamadas coincidan con lo que los mocks esperan. El TDD mantiene tu atención en la única unidad que tienes delante. De vez en cuando toca dar un paso atrás y preguntarte si las unidades tienen la forma correcta.
Cómo empezar con TDD: arregla un bug primero
Elige un bug, no una funcionalidad, para tu primer intento. Escribe un test que lo reproduzca: llama a la función con el input que lo rompe, comprueba el output que realmente quieres obtener, y míralo fallar por el motivo correcto antes de tocar la solución. La solución se convierte en el paso green, y el test se queda en la suite para que el bug no pueda volver sin que nadie se dé cuenta. Haz un commit por cada vuelta. Un rastro de commits pequeños y siempre en verde es lo que hace que git bisect sea rápido la próxima vez que algo se rompa y nadie recuerde dónde.