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.

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’s not a bug. It’s what an efficient tool does when given 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 you get 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 issue isn’t that Claude is bad at testing. It’s that “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.

This matters because the RED phase isn’t just a formality. A test that has never failed doesn’t prove anything. ImportError on RED tells you Claude doesn’t know the interface yet, which is correct. “1 passed” on RED tells you Claude already implemented the thing somewhere, which is the problem.

This isn’t specific to testing, either. The same pattern shows up wherever people push an LLM on real work: a model won’t 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.

Together they turn a request into a discipline:

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
  pytest "$FILE" -x -q 2>&1 | tail -20
elif echo "$FILE" | grep -qE "\.(ts|tsx|js|jsx)$"; then
  npx vitest run "$FILE" --reporter=verbose 2>&1 | tail -20
fi

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

Result: Claude sees test output immediately after each edit, without you asking “does it pass?” between every step.

Five anti-patterns specific to Claude

Combining RED and GREEN in one prompt. “Write a failing test and then implement it” is not TDD, it’s a sequence delivered as 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. One feature per cycle. It’s not slower when you count the debugging time on the alternative.

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, which the IFTTD corpus states across episodes 333 and 341, a pattern also confirmed independently at Devoxx (Chris Simon, 2025): lock the tests before touching the implementation, and never let the LLM modify a test to make it pass. If a test can be weakened to get green, it protects nothing. When a habit isn’t enough to hold that line, a PreToolUse hook can enforce it deterministically, since a hook that blocks actually blocks; Claude Code Under the Hood covers where that layer sits in the loop.

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, exactly the kind of judgment an LLM can be trusted to report on. Claude isn’t judging test quality here, the test execution is. Claude just runs the loop and reads the outcome.

Contact