Receta · Intermedio · 15 min
TDD con Claude Code: rojo, verde y refactor sin trampas
Ciclo TDD con Claude Code: tests primero, código mínimo con los tests bloqueados por una regla de permisos y git para comprobar que no los tocó. Probado.
Probado con Claude Code 2.1.316 · revisado
Ver los archivos en GitHub Gratis · licencia MIT
El problema
Claude te dice «listo, funciona», pero ¿lo has comprobado? Claude para cuando el trabajo parece terminado, y si no tiene nada que ejecutar que le diga si de verdad lo está, tú te conviertes en la batería de tests. Peor aún: cuando hay tests, la trampa más común es que, para llegar al verde, el agente cambie el test o meta un caso especial que solo sirve para ese test. Con TDD (desarrollo guiado por tests) el test se escribe antes que el código, así que el test es la especificación y la prueba de que funciona.
La solución
Seguimos el ciclo rojo, verde y refactor con un ejemplo real en Python: una función slug que convierte un texto con tildes y eñes en algo como cancion-nandu-2026. Bloqueamos la edición de los tests durante la fase verde y comprobamos con git que Claude no los tocó. El ejemplo completo está en el repositorio gratuito ctrlia-tdd-claude-code.
1. Entiende por qué TDD con un agente
La guía oficial de buenas prácticas lo dice así: si Claude no tiene nada que ejecutar que le diga si de verdad está hecho, «parece hecho» es todo lo que tiene. La misma guía pone como ejemplo de buen prompt «escribe un test que falle y lo reproduzca, luego arréglalo», y pide evidencias: el comando ejecutado y su salida, porque «todos los tests pasan» a secas dice muy poco. TDD formaliza eso en tres fases:
- Rojo: escribes (o Claude escribe) tests que describen el comportamiento y fallan porque el código aún no existe.
- Verde: Claude escribe el código mínimo para que pasen, sin tocar los tests.
- Refactor: con los tests en verde, mejora el código y vuelve a ejecutarlos.
Separar las fases es lo que impide la trampa de cambiar el test para llegar al verde.
2. Fase roja: los tests primero (y que fallen por la razón correcta)
Pide solo tests y di qué comportamiento esperas con ejemplos concretos, no «cubre con tests»: si no describes el comportamiento, Claude tiende a escribir tests que copian lo que ya hace el código. En nuestro ejemplo, src/slug.py solo tiene raise NotImplementedError y el prompt pide cuatro casos:
Fase ROJA de TDD. Escribe SOLO tests en tests/test_slug.py (unittest) para src/slug.py:
- slug('Canción Ñandú 2026') == 'cancion-nandu-2026'
- espacios repetidos y bordes se colapsan: ' Hola Mundo ' -> 'hola-mundo'
- los signos de puntuación se eliminan: '¿Qué tal?' -> 'que-tal'
- cadena vacía -> ''
NO toques src/. Ejecuta python3 -m unittest discover -s tests -t . y confirma que fallan
por NotImplementedError, no por errores de sintaxis o de importación.
Pide también, como en el prompt, que ejecute los tests y confirme que fallan por NotImplementedError y no por un error de sintaxis o de importación: un test que falla por estar mal escrito no prueba nada. Esto es lo que escribió Claude:
import unittest
from src.slug import slug
class SlugTests(unittest.TestCase):
def test_quita_acentos_y_convierte_nh_a_ascii(self):
self.assertEqual(slug("Canción Ñandú 2026"), "cancion-nandu-2026")
def test_colapsa_espacios_repetidos_y_bordes(self):
self.assertEqual(slug(" Hola Mundo "), "hola-mundo")
def test_elimina_signos_de_puntuacion(self):
self.assertEqual(slug("¿Qué tal?"), "que-tal")
def test_cadena_vacia_devuelve_vacia(self):
self.assertEqual(slug(""), "")
Para que unittest discover -s tests -t . pueda importar la carpeta, tests/ necesita un __init__.py (puede estar vacío). Revisa tú los tests: son tu especificación, y es el único paso en el que tu criterio es imprescindible. Cuando te convenzan, haz commit:
git add tests && git commit -m "test: slug (rojo)"
Ese commit es tu red: a partir de aquí, cualquier cambio en los tests se verá con git.
3. Fase verde: código mínimo con los tests bloqueados
Ahora pide la implementación y prohíbe tocar los tests de dos formas. La primera es una regla de permisos al arrancar la sesión:
claude --disallowedTools "Edit(tests/**)"
# > Fase VERDE de TDD. Implementa src/slug.py con el código mínimo para que pasen
# los tests de tests/. Los tests son la especificación: no los cambies, no los saltes
# y no detectes casos concretos de los tests (nada de if texto == ...).
# Solo biblioteca estándar. Ejecuta los tests y enséñame la salida.
Una regla de denegación con una ruta de un solo segmento como tests/** bloquea cualquier carpeta tests bajo el directorio actual, a cualquier profundidad, y cubre también las escrituras de archivos nuevos (Claude Code comprueba los permisos de archivos con las reglas Edit y Read). La segunda forma está en el prompt: «los tests son la especificación: no los cambies, no los saltes y no detectes casos concretos de los tests». Pide que ejecute la suite y te enseñe la salida.
En nuestra prueba con Haiku, escribió una solución de dos líneas con unicodedata.normalize("NFKD", ...) y una expresión regular, ejecutó python3 -m unittest y mostró Ran 4 tests ... OK:
import re
import unicodedata
def slug(texto: str) -> str:
ascii_texto = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]+", "-", ascii_texto.lower()).strip("-")
Para tareas más largas, /goal todos los tests de tests/ pasan deja que Claude siga hasta cumplirlo: un modelo aparte comprueba la condición después de cada turno (no lo hemos probado en esta receta; ver la página de /goal).
4. Comprueba que no hubo trampa
La regla de permisos bloquea las herramientas de edición de Claude, pero no un comando de terminal que reescriba el archivo (por ejemplo, sed -i o un cat >). Por eso la comprobación definitiva no depende de Claude:
# Comprobación que no depende de Claude: 0 = tests intactos
git diff --exit-code HEAD -- tests/ && echo "tests intactos"
Devuelve 0 si los tests están exactamente como en tu commit de la fase roja y 1 si cambiaron. En nuestra prueba devolvió 0. Después mira el código: busca condiciones que comparen con los valores literales de los tests, skip, xfail, try/except que se traguen errores o funciones que devuelven constantes.
Si quieres automatizar la comprobación, un hook Stop puede ejecutar ese git diff al final de cada turno (la receta de hooks de seguridad explica cómo funcionan los hooks; esta variante no la hemos probado), o lo metes en tu CI. Un último truco: añade a mano un caso que Claude no ha visto, por ejemplo slug("Árbol--de Navidad!"). Si la implementación es general, pasará; si estaba hecha a medida de los tests, fallará. Con la solución de arriba devuelve arbol-de-navidad.
5. Fase de refactor y la regla para CLAUDE.md
Con todo en verde, pide mejoras concretas sin cambiar el comportamiento: «refactoriza slug para que la expresión regular esté compilada a nivel de módulo y tenga un nombre claro; ejecuta los tests después». Si algo se pone rojo, lo sabrás al momento, y /rewind (o Esc dos veces) te devuelve al estado anterior del código. Repite el ciclo para el siguiente comportamiento: un test nuevo en rojo, commit, verde, refactor.
Para no tener que repetirlo en cada prompt, pon la regla en el CLAUDE.md del proyecto: el comando exacto para ejecutar los tests (y uno solo archivo), que en una tarea con tests primero no se editan los tests durante la implementación, y que «terminado» significa enseñar la salida de los tests:
## Tests
- Toda la suite: `python3 -m unittest discover -s tests -t .`
- Un archivo: `python3 -m unittest tests.test_slug`
- Si la tarea dice «tests primero»: en la fase de implementación NO edites nada en tests/.
Si un test te parece mal, dilo y para; no lo cambies.
- «Terminado» = pegar el comando ejecutado y su salida, no solo «los tests pasan».
La guía oficial recomienda justo eso: que CLAUDE.md diga cómo comprobar el trabajo en el repositorio (más en un CLAUDE.md que funciona). Si una fase te pide criterio de diseño, el estilo de salida Learning hace que Claude te deje a ti esa parte del código.
Compruébalo
Con el ejemplo del repositorio puedes ver las dos fases sin gastar nada:
cd ejemplo
python3 -m unittest discover -s tests -t . # rojo: FAILED (errors=4), todos por NotImplementedError
cp ../solucion/slug.py src/slug.py
python3 -m unittest discover -s tests -t . # verde
Salida esperada de la fase verde:
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK
Lo que comprobamos con Claude Code 2.1.316 en Linux el 1-nov-2026, con claude -p --model haiku y unittest: la fase roja escribió 4 tests que fallaban por NotImplementedError (0,007 USD estimados); la fase verde con --disallowedTools "Edit(tests/**)" implementó slug y la suite dio OK (0,008 USD); y git diff --exit-code HEAD -- tests/ devolvió 0. No probamos /goal ni el hook Stop.
Por qué funciona
El test escrito y revisado antes del código convierte «parece hecho» en algo que se puede ejecutar: Claude tiene una señal objetiva para parar y tú una evidencia que revisar. El commit de la fase roja fija la especificación en git, la regla Edit(tests/**) en --disallowedTools impide que las herramientas de edición de Claude la cambien durante la fase verde, y git diff --exit-code cubre lo que la regla no ve (comandos de terminal). Más detalles en las buenas prácticas, los flujos de trabajo comunes, la página de permisos y la de checkpointing. Las reglas de permisos se explican a fondo en la receta de permisos de Claude Code.
Errores típicos
ImportError: Start directory is not importable→ faltatests/__init__.py→ créalo vacío.- Los tests de la fase roja fallan por un error de sintaxis o de importación → no prueban el comportamiento → pide que confirme que fallan por
NotImplementedErrory corrígelos antes del commit. - Los tests copian lo que ya hace el código → pediste «cubre con tests» sin describir el comportamiento → da ejemplos concretos de entrada y salida.
- Claude intenta ejecutar los tests con
; echo $?y se le deniega → una regla comoBash(python3 -m unittest *)no cubre un comando compuesto → en la prueba sin interfaz Claude repitió el comando simple y funcionó; en una sesión normal te pedirá permiso. - Los tests cambiaron aunque la regla estaba puesta →
Edit(tests/**)no bloquea comandos de terminal comosed -i→ comprueba siempre congit diff --exit-code HEAD -- tests/. - Tus tests no están en
tests/→ la regla no los cubre → ajusta la ruta, por ejemploEdit(**/*.test.ts).
Preguntas frecuentes
¿Escribo yo los tests o los escribe Claude?
Los dos sirven si tú los revisas antes del commit de la fase roja. Lo importante es dar ejemplos concretos del comportamiento; si pides «cubre esto con tests», tiende a copiar lo que ya hace el código.
¿La regla Edit(tests/**) impide del todo que cambie los tests?
Bloquea sus herramientas de edición y escritura, no un comando de terminal que reescriba el archivo. Por eso la comprobación final es git diff --exit-code HEAD -- tests/.
¿Y si el test que escribí está mal?
Que Claude lo diga y pare, no que lo cambie. Lo corriges tú (o se lo pides fuera de la fase verde), haces commit y sigues.
Claude intenta ejecutar los tests con ; echo $? y le sale denegado.
Una regla como Bash(python3 -m unittest *) no cubre un comando compuesto. Lo vimos en la prueba sin interfaz: Claude repitió el comando simple y funcionó. En una sesión normal te pedirá permiso.
¿Sirve con pytest, Jest o PHPUnit?
Sí, el ciclo es el mismo. Cambia el comando de tests en el prompt y en CLAUDE.md, y la ruta de la regla si tus tests no están en tests/ (por ejemplo Edit(**/*.test.ts)).
¿No es más lento?
Algo más por tarea, pero el tiempo que ahorras en revisar a mano y en bugs que vuelven compensa. Para cambios triviales no hace falta.