Skip to content

Test-driven development with AI agents

Test-driven development with AI agents means a human-approved failing test exists, and fails for the right reason, before the agent writes any implementation. The agent then changes application code until the suite passes, with the test files locked. The same protocol fixes bugs: reproduce the defect as a red test, commit it, then fix the code.

This page is for developers who drive Claude Code, Codex or Cursor every day, and for the tech leads who set the loop the whole team runs. You ask the agent for a coupon helper. It returns the code and a test suite that passes, and two days later finance reports that expired coupons still get the discount. The tests were written after the code, so they proved only that the code does what the code does.

What you get from running TDD with an agent

Section titled “What you get from running TDD with an agent”
  • A four-phase loop (red, confirm red, green, refactor) where each phase is its own narrow prompt
  • Copy-paste prompts for every phase, plus a template that turns a requirements list into a test spec
  • A test-first bug-fixing protocol: reproduction test, red commit, locked tests, verified green run
  • A script that proves the red test failed before the fix and that no test changed afterwards
  • The signs that an agent is weakening your tests to force green, and how to recover when it does

Why does the test have to come first when an agent writes the code?

Section titled “Why does the test have to come first when an agent writes the code?”

An agent optimizes for the target you give it. Give it “implement the feature” and “done” means whatever the agent decides. Give it a failing test and “done” becomes a check the agent can run and cannot argue with. This is the core idea behind verifying behaviour instead of reading every diff: you review the test, which is short and states intent, and let the suite judge the implementation.

Google Cloud’s DORA team made the same point about AI-assisted delivery in its 2025 report (2025-09-23): “Without robust control systems, like strong automated testing, mature version control practices, and fast feedback loops, an increase in change volume leads to instability.” A failing test that exists before the code is the smallest unit of that control system.

Order matters for one more reason. When the same turn writes both the test and the code, the agent tends to write a test that asserts whatever its implementation does, bugs included. Splitting the turns, and having a human approve the tests in between, is what makes the green run mean something.

The classic cycle is red, green, refactor. With an agent, each phase becomes a separate instruction, and one rule holds the loop together: the turn that writes the failing test never writes the code that satisfies it.

  1. Write the tests (red). Name the behaviours and the edge cases, and forbid the implementation. You are describing a contract, not asking for a feature. Start with three to five tests for the core behaviour; add edge cases once the first slice is green.
  2. Confirm red. The agent runs the suite and shows you the failures. Each failure must be about missing behaviour, not a typo in an import or a broken fixture. Approve the tests here: this is your review of the spec.
  3. Commit the red state. Commit the test files on their own (test: …). The commit is your evidence that the test existed and failed before any fix, and the point you reset to if the green phase goes wrong.
  4. Implement to green. One instruction: make these tests pass, do not touch the test files. The agent runs the suite after each change and iterates until everything is green, including the rest of the suite.
  5. Refactor under green. Ask for a readability pass that changes implementation only. The locked tests are now the safety net that makes restructuring cheap.

How do you turn requirements into a test spec?

Section titled “How do you turn requirements into a test spec?”

Start from behaviour, not from the implementation. Concrete requirements produce concrete tests; a vague prompt produces tests that assert the function exists. Use this template when you start from a requirements document and want the agent to enumerate the cases:

Replace FEATURE, REQUIREMENTS, TEST_FILE_PATH and EXISTING_TEST_FILE. Here is the same template filled in for an Express rate limiter, where concurrency is exactly the case an agent would not test on its own:

A pure function makes a tidy demo and teaches little. Here is the loop on a production-shaped case: a service method with error paths and a mocked repository.

Phase 1: tests only. Pin the agent to writing tests and nothing else.

Phase 2: confirm red. Do not skip this step. A test that passes now asserts nothing.

Read the failure list, approve the tests, and commit them: git add src/services/pricing.test.ts && git commit -m "test: specify PricingService.applyCoupon".

Phase 3: implement to green. Only now do you authorize the implementation, and you fence off the tests.

The first rule is load-bearing. Without it, an agent that gets stuck often “fixes” the assertion instead of the code. The attempt cap follows the same logic as Stripe’s coding agents, which, as Stripe’s Alistair Gray wrote on 2026-02-09, get “at most two rounds of CI”, after which, as a follow-up post on 2026-02-19 says, the branch goes back to its human operator: a bounded loop fails loudly instead of drifting.

Phase 4: refactor under green.

Fix bugs test-first: reproduce, commit, lock, fix

Section titled “Fix bugs test-first: reproduce, commit, lock, fix”

A bug fix is the same loop with the test written against a defect instead of a feature. When you paste a stack trace and say “fix this”, the agent often changes code until the symptom disappears and declares success, with nothing that proves the defect existed or that it is gone. The test-first protocol forces that proof. It is also the answer the Developer Scorecard gives full marks for on bug fixing: a committed failing test, a lock on test files during the fix, and a verified green run.

  1. Reproduce the bug as a failing test. Give the agent the bug report or the log and forbid source changes.

  2. Commit the red test on its own. Start the message with test: reproduce and note the SHA (git rev-parse HEAD): you pass it to the verification script below.

    Terminal window
    # Terminal, repository root
    git add tests/auth/email-change.test.ts
    git commit -m "test: reproduce session invalidation on email change"
  3. Lock the test files for the fix. Use the per-tool lock in the next section. A prompt instruction alone is a request, not a guard.

  4. Ask for the fix.

  5. Verify the green run with the script below, not by reading the diff: the reproduction failed before the fix, passes after it, the suite is green, and no test file changed after the red commit.

A bug-fix pull request built this way has at least two commits: the red test, then the fix. The reproduction test stays in the suite as a permanent regression test.

How do you lock test files while the agent implements?

Section titled “How do you lock test files while the agent implements?”

Put “do not modify the test files” in every green-phase prompt, and back it with a mechanism the agent cannot talk its way past. The mechanisms differ by tool; protecting the oracle has the full, layered setup, including CODEOWNERS and a CI check the pull request cannot edit.

Add deny rules to .claude/settings.json. Deny rules block in every permission mode, and an Edit(...) rule also covers Write:

{
"permissions": {
"deny": ["Edit(tests/**)", "Edit(**/*.test.ts)", "Edit(**/__snapshots__/**)"]
}
}

Deny rules do not stop a script the agent runs from writing files. For that, add the sandbox’s filesystem.denyWrite and a PreToolUse hook that tells Claude what to do instead of editing the test; both are on the oracle page. A hook blocks only when it exits with code 2; exit 1 lets the edit through.

How do you know the green run is real without reading the code?

Section titled “How do you know the green run is real without reading the code?”

You do not need to read the implementation line by line to trust it. You need four facts, and a script can establish all of them:

  1. The reproduction or spec test failed at the red commit.
  2. It passes at HEAD.
  3. The full suite passes at HEAD.
  4. No test file changed between the red commit and HEAD.
#!/usr/bin/env bash
# scripts/verify-tdd.sh: run in the terminal or in CI on a bug-fix or TDD branch
set -euo pipefail
TEST_FILE=${1:?usage: verify-tdd.sh <test file> <red commit sha>}
RED=$(git rev-parse --verify "${2:?usage: verify-tdd.sh <test file> <red commit sha>}^{commit}")
git merge-base --is-ancestor "$RED" HEAD || { echo "FAIL: $RED is not an ancestor of HEAD"; exit 1; }
git cat-file -e "$RED:$TEST_FILE" || { echo "FAIL: $TEST_FILE does not exist at $RED"; exit 1; }
ORIG=$(git symbolic-ref -q --short HEAD || git rev-parse HEAD)
changed=$(git diff --name-only "$RED" HEAD -- 'tests/' '*.test.ts' '*.spec.ts' \
'**/__snapshots__/**' 'vitest.config.*' 'jest.config.*')
if [ -n "$changed" ]; then
echo "FAIL: test files or test config changed after the red commit:"; echo "$changed"; exit 1
fi
trap 'git checkout --quiet "$ORIG"' EXIT
git checkout --quiet --detach "$RED"
if npx vitest run "$TEST_FILE" >/dev/null 2>&1; then
echo "FAIL: $TEST_FILE passes without the fix, so it does not reproduce anything"; exit 1
fi
git checkout --quiet "$ORIG"
npx vitest run "$TEST_FILE"
npx vitest run
echo "PASS: red before the fix, green after it, tests untouched"

Pass the red commit explicitly: scripts/verify-tdd.sh src/services/pricing.test.ts <red-sha>, using the SHA the human approved at the confirm-red step. The script does not look it up from commit messages on purpose: if it took the latest test: commit, a later commit that weakens a test under the same prefix would become the new baseline, and the “no test file changed” check would pass on exactly the change it exists to catch. The script needs a clean working tree (commit or stash first). It fails if the red commit is not an ancestor of HEAD, so a SHA from another branch, where the test happens to fail for unrelated reasons, cannot stand in for the baseline and the diff check always compares this branch’s own history. It fails if the test file did not exist at the red commit, because Vitest also exits non-zero when it finds no test file, so a typo would otherwise count as “red”. The “no test file changed” check also covers snapshots and vitest.config.* / jest.config.*, since excluding a test in the config or rewriting a snapshot weakens the suite as surely as editing the test. The trap restores your branch, or the detached commit CI checked out, even when a step fails. git diff pathspecs such as '*.test.ts' match at any depth. Adjust the paths and the runner to your stack: pytest and go test work the same way, because the check depends only on the exit code.

Who signs off on what:

ArtifactWho approvesEvidence
The test list and assertionsA human, at the confirm-red stepThe failure output and the red commit
The implementationThe suite, not a readerverify-tdd.sh passing in CI
Any change to a test after the red commitA human, in a separate pull requestThe test file owner in CODEOWNERS

A green suite proves only what the tests check. To find out whether they would catch a real bug, measure oracle strength with mutation testing on the changed code, and add property-based tests where the input space is too large to enumerate by hand.

Drive the loop in Claude Code, Codex and Cursor

Section titled “Drive the loop in Claude Code, Codex and Cursor”

The phases are identical in every tool. What differs is how the agent runs the suite and how much of the test-fix-retest loop it does unattended.

Paste each phase prompt in turn in an interactive session. Put the exact test commands in CLAUDE.md (one file, related tests, full suite) so Claude does not guess between npm test and npx vitest. To make “done” mean “green”, add a Stop hook that runs the suite and exits 2 while it fails; the hooks guide has a tested version.

For a scripted green phase, run it headless with the tools it needs and nothing more:

Terminal window
# Terminal or CI (Claude Code 2.1.283)
claude -p "Implement src/services/pricing.ts so every test in src/services/pricing.test.ts passes. Run 'npx vitest run' and iterate until green. Do not edit any *.test.ts file." \
--permission-mode acceptEdits \
--allowedTools "Read,Edit,Write,Bash(npx vitest *)"

If Claude circles on the same failure, use /clear and start a fresh session with a prompt that states the one failing test and the error, rather than the whole task.

On model choice: start on the tool’s default model (Claude Opus 5.5 in Claude Code from v2.1.280 on the latest channel, GPT-6 Astra in Codex) and raise effort before switching models; in Claude Code, /effort high is the first lever for a hard green phase. Choose Claude Fable 5.1 (/model fable) deliberately for the longest, hardest implementations. The model hub has prices and trade-offs.

Use a TDD skill instead of pasting the rules every time

Section titled “Use a TDD skill instead of pasting the rules every time”

If you run this loop daily, install it as a skill so the agent follows it without the long prompt. Two are widely installed, with about 967,000 and 237,000 all-time skills.sh installs according to the LinklyAI/best-skills snapshot of 2026-09-26 (a secondary source): Matt Pocock’s tdd (red-green-refactor one vertical slice at a time, testing at agreed seams) and the Superpowers test-driven-development skill (strict red-green-refactor, written as an “Iron Law”).

Terminal window
# Terminal, repository root: one command installs the skill for all three tools
npx skills add mattpocock/skills --skill tdd -a claude-code -a codex -a cursor
npx skills add obra/superpowers --skill test-driven-development -a claude-code -a codex -a cursor

A skill changes what the agent tries to do; it does not lock anything. Keep the deny rules, profile or hook and the verification script. Skills that make agents test and debug properly compares these skills and shows the plugin installs.

What breaks when you run TDD with an agent?

Section titled “What breaks when you run TDD with an agent?”
  • The tests pass before the code exists. A mocked dependency returns a truthy default, or the import resolves to an old stub. Recovery: never skip confirm red; if it comes back green, delete the test and rewrite it against the public interface.
  • The agent weakens the test to force green. It is rarely dramatic: expect(res.status).toBe(429) becomes toBeDefined(), toBe(expected) becomes toBeTruthy(), a “flaky” test disappears, or a mock replaces the exact behaviour the test existed to check. Recovery: git checkout <red-commit> -- tests/ to restore the tests, add the lock for your tool, and rerun the green prompt. A suite that shrank is a red flag.
  • Test and fix written in the same turn. The agent writes a test that asserts its own buggy behaviour. Recovery: throw away both, and restart from the red phase in a new session.
  • The tests are trivial. “Write tests for this function” buys tests that check the function exists. Recovery: name each behaviour and give concrete input and output pairs in the prompt.
  • The tests are coupled to the implementation. They assert on private methods or internal data structures, so every refactor breaks them. Recovery: rewrite them against the public API; the Pocock tdd skill steers the agent toward this by testing at agreed seams.
  • Flaky async tests get “fixed” with sleeps. Recovery: prompt for deterministic control (“use vi.useFakeTimers() and advance time explicitly; do not add real delays”).
  • The suite is too slow to loop on. The agent waits minutes per attempt and burns context. Recovery: run only the file under test during the loop (npx vitest run src/services/pricing.test.ts; Jest 30 and later uses --testPathPatterns), then the full suite once at the end.
  • The fix breaks neighbouring tests. The reproduction goes green while three other tests go red. Recovery: the green prompt requires a full-suite run, and verify-tdd.sh fails the branch until it passes.
  • Thirty tests before any code. Analysis paralysis with extra steps. Recovery: three to five tests for the core behaviour, implement, then add edge-case tests in a separate pass that is expected to fail at first.