Recipe · Intermediate · 15 min
TDD with Claude Code: red, green and refactor with no cheating
A TDD cycle with Claude Code: tests first, minimal code with the tests locked by a permission rule, and git to check that it did not touch them. Tested.
Tested with Claude Code 2.1.316 · reviewed
See the files on GitHub Free · MIT license
The problem
Claude tells you "done, it works", but have you checked? Claude stops when the work looks finished, and if it has nothing to run that tells it whether it really is, you become the test suite. Worse: when there are tests, the most common trick is that, to reach green, the agent changes the test or adds a special case that only serves that test. With TDD (test-driven development) the test is written before the code, so the test is both the specification and the proof that it works.
The solution
We follow the red, green and refactor cycle with a real Python example: a slug function that turns text with accents and ñ into something like cancion-nandu-2026. We lock editing of the tests during the green phase and check with git that Claude did not touch them. The complete example is in the free repository ctrlia-tdd-claude-code.
1. Understand why TDD with an agent
The official best practices guide puts it this way: if Claude has nothing to run that tells it whether it is really done, "looks done" is all it has. The same guide gives "write a failing test that reproduces it, then fix it" as an example of a good prompt, and asks for evidence: the command that was run and its output, because a bare "all tests pass" says very little. TDD formalizes that in three phases:
- Red: you (or Claude) write tests that describe the behavior and fail because the code does not exist yet.
- Green: Claude writes the minimal code to make them pass, without touching the tests.
- Refactor: with the tests green, it improves the code and runs them again.
Keeping the phases separate is what prevents the trick of changing the test to reach green.
2. Red phase: tests first (failing for the right reason)
Ask only for tests and say what behavior you expect with concrete examples, not "cover it with tests": if you do not describe the behavior, Claude tends to write tests that copy what the code already does. In our example, src/slug.py only contains raise NotImplementedError and the prompt asks for four cases:
TDD RED phase. Write ONLY tests in tests/test_slug.py (unittest) for src/slug.py:
- slug('Canción Ñandú 2026') == 'cancion-nandu-2026'
- repeated spaces and leading/trailing spaces collapse: ' Hello World ' -> 'hello-world'
- punctuation is removed: '¿Qué tal?' -> 'que-tal'
- empty string -> ''
DO NOT touch src/. Run python3 -m unittest discover -s tests -t . and confirm they fail
with NotImplementedError, not with syntax or import errors.
The part about failing for the right reason matters: a test that fails because it is badly written proves nothing. This is what Claude wrote (English version of the repository, en/ejemplo/tests/test_slug.py):
import unittest
from src.slug import slug
class SlugTests(unittest.TestCase):
def test_strips_accents_and_turns_enye_into_ascii(self):
self.assertEqual(slug("Canción Ñandú 2026"), "cancion-nandu-2026")
def test_collapses_repeated_and_edge_spaces(self):
self.assertEqual(slug(" Hello World "), "hello-world")
def test_removes_punctuation(self):
self.assertEqual(slug("¿Qué tal?"), "que-tal")
def test_empty_string_returns_empty(self):
self.assertEqual(slug(""), "")
For unittest discover -s tests -t . to import the folder, tests/ needs an __init__.py (it can be empty). Review the tests yourself: they are your specification, and this is the one step where your judgment is essential. When you are happy with them, commit:
git add tests && git commit -m "test: slug (red)"
That commit is your safety net: from here on, any change to the tests will show up in git.
3. Green phase: minimal code with the tests locked
Now ask for the implementation and forbid touching the tests in two ways. The first is a permission rule when you start the session:
claude --disallowedTools "Edit(tests/**)"
# > TDD GREEN phase. Implement src/slug.py with the minimum code to make the tests in
# tests/ pass. The tests are the specification: don't change them, don't skip them
# and don't special-case the test inputs (no if text == ...).
# Standard library only. Run the tests and show me the output.
A deny rule with a single-segment path like tests/** blocks any tests folder under the current directory, at any depth, and also covers writing new files (Claude Code checks file permissions with the Edit and Read rules). The second way is in the prompt, which says: GREEN phase; implement src/slug.py with the minimal code to make the tests pass; the tests are the specification, so do not change them, do not skip them and do not special-case the test values (no if text == ...); standard library only; run the tests and show me the output.
In our test with Haiku, it wrote a two-line solution with unicodedata.normalize("NFKD", ...) and a regular expression, ran python3 -m unittest and showed Ran 4 tests ... OK (English version in en/solucion/slug.py):
import re
import unicodedata
def slug(text: str) -> str:
ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
return re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
For longer tasks, /goal all tests in tests/ pass lets Claude keep going until the condition is met: a separate model checks the condition after each turn (we did not test it in this recipe; see the /goal page).
4. Check there was no cheating
The permission rule blocks Claude's editing tools, but not a terminal command that rewrites the file (for example, sed -i or a cat >). That is why the final check does not depend on Claude:
# Check that does not depend on Claude: 0 = tests untouched
git diff --exit-code HEAD -- tests/ && echo "tests untouched"
It returns 0 if the tests are exactly as in your red-phase commit and 1 if they changed. In our test it returned 0. Then look at the code: search for conditions that compare against the tests' literal values, skip, xfail, try/except blocks that swallow errors, or functions that return constants.
If you want to automate the check, a Stop hook can run that git diff at the end of each turn (the security hooks recipe explains how hooks work; we have not tested this variant), or you put it in your CI. One last trick: add by hand a case Claude has not seen, for example slug("Árbol--de Navidad!"). If the implementation is general, it will pass; if it was tailored to the tests, it will fail. With the solution above it returns arbol-de-navidad.
5. Refactor phase and the CLAUDE.md rule
With everything green, ask for specific improvements without changing behavior: "refactor slug so the regular expression is compiled at module level and has a clear name; run the tests afterwards". If anything turns red, you will know at once, and /rewind (or Esc twice) takes the code back to its previous state. Repeat the cycle for the next behavior: a new red test, commit, green, refactor.
So you do not have to repeat it in every prompt, put the rule in the project's CLAUDE.md: the exact command to run the tests (and a single file), that in a tests-first task the tests are not edited during implementation, and that "done" means showing the test output:
## Tests
- Whole suite: `python3 -m unittest discover -s tests -t .`
- One file: `python3 -m unittest tests.test_slug`
- If the task says "tests first": in the implementation phase DO NOT edit anything in tests/.
If a test looks wrong to you, say so and stop; don't change it.
- "Done" = paste the command you ran and its output, not just "the tests pass".
The official guide recommends exactly that: that CLAUDE.md says how to verify work in the repository (more in a CLAUDE.md that works). If a phase calls for design judgment, the Learning output style makes Claude leave that part of the code to you.
Check it
With the repository's example you can see both phases without spending anything:
cd en/ejemplo
python3 -m unittest discover -s tests -t . # red: FAILED (errors=4), all from NotImplementedError
cp ../solucion/slug.py src/slug.py
python3 -m unittest discover -s tests -t . # green
The first run is red (FAILED (errors=4), all from NotImplementedError); after copying the solution, the second is green. Expected output of the green phase:
....
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK
What we checked with Claude Code 2.1.316 on Linux on 1 Nov 2026, with claude -p --model haiku and unittest: the red phase wrote 4 tests that failed with NotImplementedError (an estimated 0.007 USD); the green phase with --disallowedTools "Edit(tests/**)" implemented slug and the suite passed (0.008 USD); and git diff --exit-code HEAD -- tests/ returned 0. We did not test /goal or the Stop hook.
Why it works
A test written and reviewed before the code turns "looks done" into something you can run: Claude has an objective signal to stop and you have evidence to review. The red-phase commit pins the specification in git, the Edit(tests/**) rule in --disallowedTools stops Claude's editing tools from changing it during the green phase, and git diff --exit-code covers what the rule cannot see (terminal commands). More details in the best practices, common workflows, permissions and checkpointing pages. Permission rules are explained in depth in the Claude Code permissions recipe.
Common mistakes
ImportError: Start directory is not importable→tests/__init__.pyis missing → create it empty.- Red-phase tests fail because of a syntax or import error → they do not test the behavior → ask Claude to confirm they fail with
NotImplementedErrorand fix them before the commit. - The tests copy what the code already does → you asked to "cover it with tests" without describing the behavior → give concrete input and output examples.
- Claude tries to run the tests with
; echo $?and is denied → a rule likeBash(python3 -m unittest *)does not cover a compound command → in the headless test Claude repeated the simple command and it worked; in a normal session it will ask you for permission. - The tests changed even though the rule was set →
Edit(tests/**)does not block terminal commands likesed -i→ always check withgit diff --exit-code HEAD -- tests/. - Your tests are not in
tests/→ the rule does not cover them → adjust the path, for exampleEdit(**/*.test.ts).
FAQ
Do I write the tests or does Claude?
Both work as long as you review them before the red-phase commit. What matters is giving concrete examples of the behavior; if you ask it to "cover this with tests", it tends to copy what the code already does.
Does the Edit(tests/**) rule completely stop it from changing the tests?
It blocks its editing and writing tools, not a terminal command that rewrites the file. That is why the final check is git diff --exit-code HEAD -- tests/.
What if the test I wrote is wrong?
Claude should say so and stop, not change it. You fix it (or ask for it outside the green phase), commit and carry on.
Claude tries to run the tests with ; echo $? and gets denied.
A rule like Bash(python3 -m unittest *) does not cover a compound command. We saw it in the headless test: Claude repeated the simple command and it worked. In a normal session it will ask you for permission.
Does it work with pytest, Jest or PHPUnit?
Yes, the cycle is the same. Change the test command in the prompt and in CLAUDE.md, and the rule's path if your tests are not in tests/ (for example Edit(**/*.test.ts)).
Isn't it slower?
A bit more per task, but the time you save on manual review and on bugs that come back makes up for it. For trivial changes it is not needed.