Skip to main content
FB.
Intermediate 40 min claude-codetddtestinghooksworkflow

TDD with Claude Code

By default, Claude writes tests that pass. Two words in the prompt change that.

Updated Jul 22, 2026

Prerequisites

  • Claude Code installed and configured (at minimum Level 1 of the setup guide)

What you'll set up

  • Copy-paste RED/GREEN/REFACTOR prompts for pytest (Django) and Vitest (React)
  • A PostToolUse hook that auto-runs the right test suite after every file edit

Claude’s default behavior when you ask it to write a test is to write a test that passes. It builds the implementation in its head, writes the assertion to match, and delivers green. That is what an efficient tool does with an ambiguous instruction. The result looks like TDD but has none of the properties that make TDD useful: you get coverage numbers without regression detection, and confidence without evidence.

Same request compared: default prompt lets Claude solve internally and deliver a passing test, disciplined prompt with FAILING and Stop produces only the test contract
One word changes what gets built first: the test or the answer.

The structural problem

The two instructions, “write a test for X” and “write a FAILING test for X”, produce fundamentally different outputs. The first triggers a solve-and-verify loop internally before generating any code. The second forces Claude to write only the test contract, without knowing what will satisfy it.

The RED phase verifies that the test can detect a missing implementation. ImportError on RED tells you Claude does not know the interface yet, which is correct. “1 passed” on RED tells you Claude already implemented the thing somewhere, which is the problem.

The same pattern shows up wherever people push an LLM on real work: a model will not constrain itself. The constraint has to be engineered in.

The two words that fix it

The first word is FAILING, in uppercase. In practice, writing it uppercase cut down the cases where Claude slipped an implementation past the RED phase, though that’s an observation from repeated use, not a result measured across a large sample. “Write a failing test” is ambiguous enough that Claude might still run the internal solve loop and produce a test that technically fails but only because of a contrived assertion. FAILING as a standalone emphasis cuts through that.

The second is Stop. at the end of the prompt, or the equivalent Do NOT implement yet. Without it, “write a test for the subscription endpoint” is syntactically the first half of “write a test for the subscription endpoint, then implement it.” Claude’s training optimizes for task completion. It will complete the task. Stop. is the explicit boundary.

Use both words in the same request:

Write a FAILING test for [feature]. Do NOT implement yet. Stop.
RED GREEN REFACTOR cycle: write a failing test and stop, write the minimum implementation to pass, refactor without changing behavior, looping back to RED
The cycle Claude skips by default, forced back into place by two words.

Three prompts per phase

Copy these verbatim. Adjust the feature description and file paths, leave everything else intact.

RED phase

Write a FAILING test for [feature description].
File: [test file path]
Do NOT implement the feature. Do NOT create any implementation files. Stop.

The test should fail with ImportError, NameError, or a clear assertion error.

For Django/pytest variant (tests/api/test_subscriptions.py, DRF APITestCase):

Write a FAILING test for POST /api/v1/subscriptions/ creating a new subscription.
File: tests/api/test_subscriptions.py
Use APITestCase. Assert response.status_code == 201 and response.data["id"] is not None.
Do NOT create views.py or urls.py. Do NOT implement anything. Stop.

For React/Vitest variant:

Write a FAILING test for SubscriptionCard displaying the plan name and price.
File: src/components/SubscriptionCard.test.tsx
Use @testing-library/react. Assert the plan name and formatted price are in the document.
Do NOT create SubscriptionCard.tsx. Stop.

GREEN phase

The test at [test file path] is failing with [paste the actual error output].
Write the minimum implementation to make it pass. Nothing else.
File: [implementation file path]

“Minimum” is load-bearing here. Without it, Claude will implement the feature, handle edge cases, add error handling, and return a full service layer. That’s all work that belongs in later cycles.

REFACTOR phase

The tests at [test file path] are passing.
Refactor [implementation file path] for readability and structure.
Do not change behavior. Run the tests to confirm they still pass after each change.

Verifying the cycle works

Each phase has a specific success signature. If you see a different outcome, the cycle broke.

RED: the test runs and fails with ImportError, NameError, or an AssertionError referencing a value you didn’t hardcode. If it passes, stop immediately and investigate before moving on.

GREEN: all tests pass, and Claude has created exactly the files you asked for, nothing more. If Claude added a serializer you didn’t ask for, it extrapolated. That work belongs in the next cycle, written with its own test first.

REFACTOR: all tests still pass after the refactor. If any test breaks, the refactor changed behavior. Roll back and start the phase again with a more constrained prompt.

The PostToolUse auto-run hook

This hook runs your test suite automatically after every Edit or Write on a test file, so you see failures the moment they’re introduced.

Create .claude/hooks/run-tests.sh:

#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
if [[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]]; then exit 0; fi

FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
if [[ -z "$FILE" ]]; then exit 0; fi

# Only fire on test files
if ! echo "$FILE" | grep -qE "(test_|\.test\.|\.spec\.)"; then exit 0; fi

ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
cd "$ROOT"

if echo "$FILE" | grep -qE "\.py$"; then
  OUTPUT=$(pytest "$FILE" -x -q 2>&1 | tail -20)
elif echo "$FILE" | grep -qE "\.(ts|tsx|js|jsx)$"; then
  OUTPUT=$(npx vitest run "$FILE" --reporter=verbose 2>&1 | tail -20)
else
  exit 0
fi

# A PostToolUse hook that just prints and exits 0 sends its output to the debug
# log, not to the model. additionalContext is what reaches Claude.
jq -n --arg ctx "$OUTPUT" \
  '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}'
exit 0

Register it in .claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/run-tests.sh",
            "timeout": 30
          }
        ]
      }
    ]
  }
}
chmod +x .claude/hooks/run-tests.sh

The jq line at the end makes the result reach Claude. A PostToolUse hook that prints to stdout and exits 0 sends its output to the debug log, where the model never reads it. Three outputs reach Claude from a PostToolUse hook:

Hook outputWhat Claude receives
exit 0 + plain stdoutnothing (debug log only)
exit 0 + JSON additionalContextthe text, injected as context
exit 2 + stderrthe text, as a blocking error

This hook uses additionalContext, so Claude reads the test result after every edit without you prompting “does it pass?”, and nothing blocks. Swap it for exit 2 on failure when you want a red test to stop Claude instead of just informing it. That step from informing to blocking is the same line the enforcement hooks in the next section are built on.

Five anti-patterns specific to Claude

Combining RED and GREEN in one prompt. “Write a failing test and then implement it” asks for a sequence in a single unit. Claude will write both and optimize them against each other. The test will be exactly as hard to fail as the implementation is easy to write.

Not checking the failure reason. ImportError and AssertionError are different signals. ImportError means the implementation doesn’t exist yet, which is correct for RED. AssertionError where the actual value is close to the expected value means Claude partially implemented something during the test-writing step. Both are red, but they mean different things.

Tests that assert nothing useful. assert result passes as long as result is truthy. assert result.status_code == 201 catches regressions. Claude defaults to the former when given a vague RED prompt. Specify the assertion shape explicitly in the prompt.

Multiple features per cycle. “Write tests for user registration, login, and password reset” leads to one RED covering three features, one GREEN implementing all three at once, and a REFACTOR touching too much to reason about safely. Keep one feature per cycle to avoid the debugging time that follows.

Skipping REFACTOR. Claude writes minimum implementations that pass tests, and minimum is not the same as readable. The REFACTOR step turns “technically correct” into “maintainable.” Skipping it means the next cycle starts with code that’s already hard to modify.

Five TDD anti-patterns specific to Claude: combining RED and GREEN, not checking the failure reason, tests that assert nothing useful, multiple features per cycle, skipping REFACTOR
All five reduce to the same rule: lock the tests before touching the implementation.

All five reduce to one rule, stated by Quentin Adam on IFTTD episode 341 and confirmed independently at Devoxx Belgium 2025 (Chris Simon, TDD & DDD From the Ground Up): lock the tests before touching the implementation, and never let the LLM modify a test to make it pass. Adam’s phrasing is the one that sticks.

Tell the model the tests have to pass and it behaves like Ultron deciding the problem with the planet is humanity, so it deletes your tests and reports green. A test weakened to get green protects nothing. When a habit does not hold that line, a hook can.

nizos/tdd-guard (2.3k stars, MIT, actively maintained) packages exactly that: a PreToolUse hook on Write|Edit|MultiEdit that inspects each change before it lands and blocks the ones that skip a test or over-implement. Its decision mechanism has a limit. A PreToolUse denial is deterministic, while tdd-guard asks an LLM (Claude Sonnet by default) whether a change breaks the cycle. That judgment produces its occasional false positives. This is a hard gate around a soft judgment. Claude Code Under the Hood covers where that layer sits in the loop.

When the loop won’t go green

A TDD loop that retries on its own needs a floor, and mine ran without one for months. The Stop hook that re-ran the suite pushed Claude back into the cycle on every red, capped at ten iterations, and when it reached that cap it gave up quietly. Nothing surfaced. The tokens were already spent.

The fix is a second Stop hook. Its exit codes read backwards from every other event:

Exit codeEffect on a Stop hook
exit 0let Claude stop
exit 2block the stop, Claude keeps going

An andon cord, the rope that halts the assembly line, therefore cannot work by blocking. It has to exit 0. Mine stops the line by taking the fuel away instead: it deletes the flag files that the TDD loop hook reads before deciding to exit 2, sends me a notification, and prints the reason to stderr. Three turns in a row ending on a failing gate command (tsc, vitest, eslint) and the automation cuts itself off.

Two details that earn their keep. The threshold and the kill switch are environment variables (CLAUDE_ANDON_THRESHOLD, default 3, and CLAUDE_ANDON_SKIP=1), because the first thing you want while debugging the hook is a way to turn it off. And the counter resets on any passing gate, so a flaky test can’t walk you to the threshold over an afternoon.

I shipped this on 20 July 2026, eight days after an audit of my own pipeline found that ten-iteration cap terminating in silence. It had been running that way for a long time and I never noticed, because nothing was wired to tell me.

Mutation testing

Once you have a passing suite, ask Claude to verify the tests actually catch regressions:

In [implementation file path], make one mutation: change a > to >=,
remove a condition, or flip a boolean. Run the tests. Report whether they
catch it. Then revert and try a different mutation.

If the tests pass through a meaningful change, they’re not covering what you think they are. Claude can run several mutations in sequence and report which ones slip through, faster than reading the test file line by line. A suite that survives > becoming >= on a boundary condition is a suite that will miss the same regression in production.

This works because the test’s pass/fail state after a mutation is a binary, deterministic signal. Test execution judges the result; Claude runs the loop and reads the outcome.

The manual prompt fits one function at a time, when you want to sanity-check a suite you just wrote. For a whole codebase, Trail of Bits ships a mutation-testing skill in its Claude Code marketplace (trailofbits/skills, 6.1k stars, the skill itself around 2.4k installs) that configures mewt or muton campaigns, scopes the targets, and tunes the timeouts that otherwise make full-suite mutation runs unusable. Its companion skill genotoxic goes further. It triages the mutants that survive against a call graph, separating dead code from genuinely missing tests, and runs necessist to find assertions weak enough that deleting them changes nothing. That last check automates the “tests that assert nothing useful” anti-pattern from earlier in this guide.


YSNK

(You should now know)

  • Only three things ever reach Claude from a PostToolUse hook: plain stdout on exit 0 goes to the debug log only, JSON additionalContext on exit 0 gets injected as context, and stderr on exit 2 arrives as a blocking error. Printing to stdout and hoping Claude sees it does nothing
  • tdd-guard’s block is deterministic, a PreToolUse hook that denies actually denies, but the ruling that triggers it comes from an LLM judging the change. A hard gate wrapped around a soft judgment, which is why it has occasional false positives
  • Stop hook exit codes read backwards from every other hook event: exit 0 lets Claude stop, exit 2 blocks the stop and keeps it going. An automated retry loop that needs a kill switch can’t block its own way out, it has to remove the fuel instead (deleting the flag file the loop reads) and exit 0
  • A ten-iteration retry cap that gives up silently on failure is worse than no cap: the tokens are already spent and nothing surfaces unless something is wired to say so
  • Mutation testing catches suites that don’t test what they claim: flip a > to >= on a boundary condition, rerun, and if the tests still pass, they’re not covering that boundary at all
Contact