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 red-green-refactor loop with an agent
Section titled “The red-green-refactor loop with an agent”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.
- 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.
- 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.
- 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. - 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.
- 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:
Run the four phases on a service method
Section titled “Run the four phases on a service method”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.
-
Reproduce the bug as a failing test. Give the agent the bug report or the log and forbid source changes.
-
Commit the red test on its own. Start the message with
test: reproduceand note the SHA (git rev-parse HEAD): you pass it to the verification script below.Terminal window # Terminal, repository rootgit add tests/auth/email-change.test.tsgit commit -m "test: reproduce session invalidation on email change" -
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.
-
Ask for the fix.
-
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.
Use a permission profile (beta, Codex CLI 0.138.0 or later) that makes the test directory read-only inside the sandbox. In ~/.codex/config.toml:
[permissions.locked-tests]extends = ":workspace"
[permissions.locked-tests.filesystem.":project_roots"]"tests" = "read"Select it for a run with codex exec -c default_permissions=locked-tests "…". In Codex CLI 0.157.1, a glob such as "**/*.test.ts" accepts only deny access, which also blocks reading, so colocated test files need the CI check below instead. Do not combine a profile with --sandbox or --approve-for-me: OpenAI says the profile and legacy sandbox systems “do not compose”. Also put the rule in AGENTS.md so the agent knows why its write failed.
Add a project rule (see Cursor’s Rules docs for the file location and format) that says “Never modify test files during an implementation task; if a test looks wrong, stop and explain.” Then add a Cursor hook that blocks edits to your test globs: Cursor hooks “run before or after defined stages of the agent loop and can observe, block, or modify behavior” (checked 2026-08-28). We could not re-check the hook event names on 2026-09-26, so take them from Cursor’s hooks reference.
A lock on your laptop does not travel to Cursor’s Cloud Agents, which run in their own VMs. For those, the CI check below is the enforcement.
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:
- The reproduction or spec test failed at the red commit.
- It passes at
HEAD. - The full suite passes at
HEAD. - 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 branchset -euo pipefailTEST_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 1fi
trap 'git checkout --quiet "$ORIG"' EXITgit 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 1figit checkout --quiet "$ORIG"
npx vitest run "$TEST_FILE"npx vitest runecho "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:
| Artifact | Who approves | Evidence |
|---|---|---|
| The test list and assertions | A human, at the confirm-red step | The failure output and the red commit |
| The implementation | The suite, not a reader | verify-tdd.sh passing in CI |
| Any change to a test after the red commit | A human, in a separate pull request | The 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 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.
Run each phase as its own thread, so the test-writing conversation does not crowd the implementation context. For a scripted green phase:
# Terminal or CI (Codex CLI 0.157.1)codex exec -c default_permissions=locked-tests \ "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 touch the test files."locked-tests is the profile from the previous section; without it, use -c default_permissions=":workspace". A failing test is ordinary command output, not an approval event, so the loop runs without prompting you. To try two implementations against the same locked tests, run each in its own worktree (codex exec --worktree …) so the runs do not collide.
Use Agent in the IDE and let it run the tests itself. Whether a test command runs without asking depends on your run mode, which Cursor documents on its Run modes page under Agent security. We could not re-check the mode names on 2026-09-26, so pick the mode there; for an unattended loop, never choose one that skips Cursor’s safety checks.
Keep the phases as separate chats, and use Plan Mode for the red phase when you want the test list before any file is written. Watch the diff view: if a green-phase edit touches a *.test.ts file, reject it and restate the rule. Because you committed the red state, git reset --hard to that commit is always a clean way back.
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, repository root: one command installs the skill for all three toolsnpx skills add mattpocock/skills --skill tdd -a claude-code -a codex -a cursornpx skills add obra/superpowers --skill test-driven-development -a claude-code -a codex -a cursorA 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)becomestoBeDefined(),toBe(expected)becomestoBeTruthy(), 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
tddskill 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.shfails 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.