Atlas
TechnicalHow-to

Test-First with Claude Code: Failing Tests Before Code

Run test-first development with Claude Code: commit failing tests, block edits to them with a hook, finish on a /goal or Stop hook, then review in a fresh context.

The short version

  • Test-first suits a coding agent because a failing test gives it a pass/fail result it can read and iterate against without you.
  • Write and commit the failing tests in one session, then implement in a fresh one so the code is not shaped around tests the same session just wrote.
  • A PreToolUse hook that denies edits to test files enforces what a prompt only requests, but it does not see changes made through shell commands, so check git diff on the test folder too.
  • End the run with /goal or a Stop hook tied to the test command. Put a turn limit in the /goal condition; a Stop hook is overridden after eight consecutive blocks.
  • Finish with a review in a fresh subagent, told to report only gaps that affect correctness.
On this page
  1. The short version
  2. Why test-first suits a coding agent
  3. Step 1: Write the failing tests in their own session
  4. Step 2: Implement in a fresh session
  5. Step 3: Make the tests hard to cheat on
  6. Step 4: Gate the finish with a Stop hook
  7. Step 5: Review in a fresh context
  8. How this looks across a team
  9. Common mistakes
  10. Conclusion
  11. Questions people ask

Can you do test-driven development with Claude Code? Yes, and it suits agents well: a failing test is a check the agent can run, read and iterate against until it passes. The catch is that an agent that writes both the tests and the code can bend one to fit the other. The workflow below keeps them apart.

Anthropic's best-practices guide states the underlying idea: Claude stops when the work looks done, and without a check it can run, "looks done" is the only signal available, so you become the verification loop. Test-first is a direct way to build that check before any code exists.

Why test-first suits a coding agent

A test suite turns "looks done" into something measurable: the agent does the work, runs the check, reads the result and iterates until it passes. The docs list a test suite, a build exit code, a linter, or a script that diffs output against a fixture as valid checks.

Writing the tests first adds three things:

  • A spec you can review. Test names and assertions are quicker to approve than a large diff.
  • A fixed target. The implementation session cannot redefine "correct" halfway through.
  • A natural stopping point. "All tests in this folder pass" is a condition both you and the agent can verify.

The risk is the mirror image. If the same agent controls the tests, one way to reach green is to weaken a test. The rest of this guide is about closing that door. The best-practices guide itself suggests a version of the split: have one Claude write tests, then another write code to pass them.

Step 1: Write the failing tests in their own session

Be specific about the behaviour and the cases. The best-practices guide contrasts "add tests for foo.py" with a scoped version that names the edge case and says to avoid mocks. For a bug, it suggests asking for a failing test that reproduces the issue before the fix.

If the change touches several files or you are unsure of the approach, begin in plan mode: start with claude --permission-mode plan, or press Shift+Tab until the status bar shows plan mode. The guide says planning pays off most when you are unsure of the approach or the change spans multiple files, and that you can skip it if you could describe the diff in one sentence.

A prompt for the test session might look like this:

text
Read src/billing/ and the existing tests in test/billing/.
Write tests for a new proration function: upgrades mid-cycle,
downgrades mid-cycle, a zero-length cycle, and a cancelled
subscription. Follow the style of the existing tests. Do not
write the implementation. Run the tests and confirm they fail
for the right reason (function missing or wrong result), not
because of a syntax or import error.

The last sentence matters. A test that fails because of a typo proves nothing. Ask the agent to show the failing output and read it yourself. Then commit the tests:

bash
git add test/billing/
git commit -m "Add failing tests for proration"

Committing gives you a clean baseline: any later change to those files shows up in git diff.

Step 2: Implement in a fresh session

Run /clear, or open a new session, before implementing. The guide's advice on context applies: a long session carrying the whole test-writing conversation performs worse than a clean one, and a fresh session means the implementer meets the tests as a given, not as something it authored.

Give it a precise brief and a way to stop. The /goal command sets a completion condition, and after each turn a model checks whether it holds. The docs recommend a condition with one measurable end state, a stated check and the constraints that matter:

text
/goal npm test -- test/billing exits 0, no file under test/ is modified,
and lint is clean. Stop after 20 turns if not met.

Details worth knowing from the docs:

  • The evaluator judges the condition against what Claude has surfaced in the conversation. It does not run commands or read files itself, so the condition must be something Claude's own output can demonstrate, such as a test run.
  • A turn or time clause ("or stop after 20 turns") bounds how long the goal runs. Claude reports progress against it and the evaluator judges it from the conversation, so it is a stated limit rather than a hard cap.
  • A goal does not change your permission mode. In manual mode Claude still asks before tool calls your settings do not already allow, such as the test command. Run /goal in auto mode, or pre-approve the test command, if you want it unattended.
  • /goal on its own shows status, including turns evaluated and token spend. /goal clear removes it.
  • Evaluation tokens are billed on the small fast model and the docs call them typically negligible next to main-turn spend.

If you prefer a deterministic gate over a model-judged one, use a Stop hook instead (Step 4).

Step 3: Make the tests hard to cheat on

A line in the goal condition is a request. A hook is enforcement. Hooks run your commands at fixed points in Claude Code's workflow. The docs state that a PreToolUse hook returning a deny blocks the tool even in bypassPermissions mode. Exit code 2 also blocks, and what you write to stderr goes back to Claude as the reason.

Here is a guard that refuses edits to files under test/. The hook input arrives as JSON on stdin. For the file tools the path should be in tool_input.file_path, but confirm that with the piped-sample method the docs recommend before you rely on it:

bash
#!/bin/bash
# .claude/hooks/protect-tests.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

case "$FILE" in
  */test/*)
    echo "Blocked: tests are fixed for this task. Change the code, not the tests." >&2
    exit 2
    ;;
esac
exit 0
json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-tests.sh"
          }
        ]
      }
    ]
  }
}

Put it in .claude/settings.json to share it with the team, make the script executable, and run /hooks to confirm it is registered. Test the script by hand:

bash
echo '{"tool_name":"Edit","tool_input":{"file_path":"/repo/test/billing/a.test.ts"}}' | .claude/hooks/protect-tests.sh
echo $?   # expect 2

There is a gap. Claude can also change files through shell commands, and an Edit|Write matcher does not see those. The docs suggest a Stop hook that scans the working tree once per turn, or matching Bash as well and listing changed files with git status --porcelain. For this workflow the simplest check is against the committed baseline:

bash
git diff --quiet HEAD -- test/ || echo "tests changed"

Fold that into the Stop hook below, or inspect git diff -- test/ yourself before merging. Because you committed the tests in Step 1, the answer is unambiguous.

Step 4: Gate the finish with a Stop hook

A Stop hook fires whenever Claude finishes responding. Stop hooks use a top-level decision: "block" with a reason, which Claude takes as its next instruction. This one runs the suite and blocks the stop while it fails:

bash
#!/bin/bash
# .claude/hooks/tests-must-pass.sh
if ! OUT=$(npm test -- test/billing 2>&1); then
  jq -n --arg r "Tests still failing. Fix the code, not the tests:
$(echo "$OUT" | tail -40)" '{decision: "block", reason: $r}'
fi
exit 0

Two limits to plan for. First, Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call from Claude in between. If the work legitimately needs more iterations, the docs name CLAUDE_CODE_STOP_HOOK_BLOCK_CAP for raising the cap. Second, the docs say a hook should parse stop_hook_active from its input and exit early when it is true, to avoid loops. That is a trade-off: exiting early lets Claude stop with red tests. A model-judged /goal with a turn limit may suit long runs, and a Stop hook short ones.

Do not use both blindly. They both fire after every turn, so pick the one whose failure mode you prefer.

Step 5: Review in a fresh context

Green tests show the code matches the tests. They do not show the tests were enough. The docs recommend a reviewer running in a fresh subagent context, which sees only the diff and the criteria you give it, not the reasoning that produced the change. Name the work, the plan and what counts as a finding:

text
Use a subagent to review the proration diff against PLAN.md. Check
that every requirement is implemented, the listed edge cases have
tests, and nothing outside the task's scope changed. Report gaps
that affect correctness, not style preferences.

The last clause is deliberate. The guide warns that a reviewer prompted to find gaps will usually report some even when the work is sound, and chasing every finding leads to over-engineering. It advises telling the reviewer to flag only gaps that affect correctness or the stated requirements.

How this looks across a team

The same ordering works at team scale, with a person approving each handoff:

  1. Tests written and reviewed by a person.
  2. Implementation against locked tests.
  3. Independent review of the diff.
  4. Merge only on a green run of the project's own checks.

Atlas, which runs a team of AI engineers on Claude Code, builds part of this order into its board: building waits for an approved architecture, a signed-off design and failing tests, and completion is refused until the work is on main or queued to land and the product's rules pass. You can get most of the benefit on a single machine with the steps above. If you want that ordering without wiring it yourself, see Atlas.

Common mistakes

  • Tests that fail for the wrong reason. Always read the failing output before committing.
  • Mocking everything. The guide's own example asks for a test that avoids mocks; heavily mocked tests can pass while the real system fails.
  • An open-ended goal. Without a turn or time clause, a stuck run can keep spending your plan.
  • Correcting the same issue repeatedly. After two failed corrections the docs advise /clear and a better prompt.
  • Trusting the summary. Ask for the test output as evidence, not the agent's statement that tests pass.

Conclusion

Test-first with an agent is a separation-of-duties problem. Write failing tests in one session, commit them, implement in another, block edits to the tests with a hook, end the run on a measurable condition with a turn limit, and have a fresh reviewer look at the diff. Each step is a few lines of configuration, and together they let you step away from a session without handing over the definition of "correct".

Questions people ask

Can Claude Code do test-driven development?

Yes. Have it write failing tests first, commit them, then implement in a fresh session until the tests pass. Anthropic's best-practices guide recommends giving Claude a check it can run, and a test suite is the most direct one.

How do I stop Claude Code from editing the tests to make them pass?

Use a PreToolUse hook on Edit and Write that exits with code 2 for test paths, and state in your /goal condition that no test file may change. Claude can also change files through shell commands, which that matcher does not see, so check git diff on the test folder before merging.

What is the difference between /goal and a Stop hook?

Both fire after every turn. /goal is a condition for the current session only, judged by a small fast model from what appears in the conversation. A Stop hook lives in your settings and can run your own script, so the check is deterministic.

Why does my Stop hook stop blocking after a few tries?

Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call from Claude in between. If the work needs more iterations, raise the limit with the CLAUDE_CODE_STOP_HOOK_BLOCK_CAP environment variable.

Sources

  1. Best practices for Claude Code
  2. Keep Claude working toward a goal (/goal)
  3. Automate actions with hooks

Researched and drafted with Claude, then checked against the sources above. Atlas is an independent product, not affiliated with Anthropic or OpenAI. Claude and Claude Code are trademarks of Anthropic; Codex is a product of OpenAI.