EN · Read in English Explorar gratis

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.

Portada: TDD con Claude Code: rojo, verde y refactor sin trampas

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:

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

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.

Toda la biblioteca