Test-Driven Development with AI
Write failing tests first, then let Claude Code, Copilot, or Cursor implement — the AI TDD feedback loop
title: "Test-Driven Development with AI" description: "Write failing tests first, then let Claude Code, Copilot, or Cursor implement — the AI TDD feedback loop" section: "Workflows" readTime: "14 min"
Test-Driven Development with AI
TDD and AI agents are a natural pair. You write the spec (the test); the agent writes the implementation. The test suite becomes the ground truth that keeps the agent from hallucinating.
Why TDD Works Better with AI
Traditional AI code generation has a key failure mode: the agent produces code that looks right but doesn't actually work. Tests expose this immediately. When you adopt a TDD workflow:
- The test file is the unambiguous specification — no prose misinterpretation
- Each red→green cycle gives the agent a tight feedback loop
- The agent can run the tests itself and self-correct without your involvement
- Regressions are caught before you ever review the output
The Core Loop
You write: failing test
↓
Agent runs: npm test → sees RED
↓
Agent writes: minimal implementation
↓
Agent runs: npm test → sees GREEN
↓
Agent refactors (optional)
↓
Repeat for next test
Claude Code TDD
Setup: Give Claude the Test Harness
# Describe the loop explicitly in your prompt
claude "I want to use TDD. I'll write failing tests in __tests__/.
You should run npm test after each change and keep iterating until all tests pass.
Never skip a test or mark it as skip/todo to make it pass."Running the Loop Without Prompts
Claude needs to run tests and edit files repeatedly without stopping to ask. Prefer these options, safest first:
1. Pre-approve exactly what the loop needs in .claude/settings.json:
{
"permissions": {
"allow": ["Bash(npm test)", "Bash(npm run test *)", "Edit(**/src/**)"]
}
}2. Auto mode — a classifier model reviews actions instead of you and blocks risky ones (scope escalation, unknown infrastructure). It is the built-in starting mode for interactive sessions in recent Claude Code versions; start in it explicitly with:
claude --permission-mode auto "Implement the UserService class to pass all tests in __tests__/user.test.ts"3. Bypass permissions (--dangerously-skip-permissions, equivalent to --permission-mode bypassPermissions) skips permission prompts entirely. Use it only in isolated containers or VMs.
claude --dangerously-skip-permissions "Implement the UserService class to pass all tests in __tests__/user.test.ts"Example: Feature Implementation via TDD
# 1. Write your test first
cat > __tests__/auth.test.ts << 'EOF'
describe('hashPassword', () => {
it('returns a bcrypt hash', async () => {
const hash = await hashPassword('mypassword');
expect(hash).toMatch(/^\$2[ab]\$/);
});
it('different calls produce different hashes', async () => {
const h1 = await hashPassword('same');
const h2 = await hashPassword('same');
expect(h1).not.toBe(h2);
});
});
EOF
# 2. Hand off to Claude
claude "Implement hashPassword in src/lib/auth.ts to pass all tests in __tests__/auth.test.ts.
Run npm test to verify. Use bcryptjs."Make the Tests a Hard Gate with Hooks
Prompt instructions are advisory; hooks are deterministic. Two useful patterns in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "npm test --silent 2>&1 | tail -20" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "npm test --silent >/dev/null 2>&1 || { echo 'Tests are failing. Fix them before finishing.' >&2; exit 2; }" }]
}
]
}
}- The
PostToolUsehook runs the suite after every file edit. - The
Stophook blocks Claude from ending its turn while tests fail: exit code 2 prevents stopping and feeds the stderr message back to Claude. After stop hooks have continued the turn eight times in a row, Claude Code overrides the next block and ends the turn; the count resets each time Claude calls a tool, so a suite Claude keeps trying to fix can run longer (setCLAUDE_CODE_STOP_HOOK_BLOCK_CAPto raise the cap). The hook's input includesstop_hook_active, which istruewhen Claude is already continuing because of a stop hook — check it to avoid blocking on a condition that will never resolve.
For a session-long target without writing a script, set the check as a /goal condition (for example, all tests in __tests__/ pass); an evaluator re-checks it after every turn.
Separate Test Writer and Implementer
A fresh context isn't biased toward code it just wrote. Have one Claude session write the tests, then another write code to pass them — or ask Claude to "write a failing test that reproduces the issue, then fix it" in a single prompt when the spec is small.
Handling Test Failures Mid-Session
If Claude gets stuck in a loop, give it a constraint:
The test has been failing for 3 attempts. Stop and explain what's blocking you
before trying again.
If you've corrected Claude more than twice on the same issue, the context is cluttered with failed approaches. Press Esc twice (or run /rewind) to roll back to a checkpoint, or /clear and start fresh with a sharper prompt that includes what you learned.
GitHub Copilot TDD
Inline Suggestions from Tests
Copilot uses the files open in your editor to understand what you're writing — write the test file first, keep it open, and open the implementation file side-by-side (close unrelated files). Ghost text suggestions can then draw on the test.
// auth.test.ts — write this first
import { hashPassword, verifyPassword } from './auth';
test('hashPassword produces bcrypt hash', async () => {
const hash = await hashPassword('secret');
expect(hash).toMatch(/^\$2[ab]\$/);
});
// auth.ts — open this file, Copilot suggests the implementation
export async function hashPassword(password: string): Promise<string> {
// Copilot suggests a body here, informed by the open test file
}Agent Mode TDD
Open the Chat view (Ctrl+Alt+I) and select Agent from the agents dropdown:
I've written failing tests in src/__tests__/cart.test.ts.
Implement the CartService class in src/services/cart.ts to make all tests pass.
Run the tests to verify before finishing.
The Copilot agent can run npm test in the terminal and iterate on the results. Whether it asks first depends on your approval settings; chat.tools.terminal.autoApprove lets you pre-approve specific commands.
Cursor TDD
Auto-Run + Hooks Pattern
Cursor's Run Mode controls whether the agent's terminal commands run without asking: Allowlist runs allowlisted commands (such as npm test) without approval, Auto-review also sandboxes or classifier-reviews other commands, and Run Everything runs every tool call automatically.
Cursor also supports hooks. Put them in .cursor/hooks.json in the project (or ~/.cursor/hooks.json for your user) and use the afterFileEdit event, which fires after the Agent edits a file:
{
"version": 1,
"hooks": {
"afterFileEdit": [{ "command": ".cursor/hooks/run-tests.sh" }]
}
}Project hooks run from the project root. The script can run a command like:
npm test -- --passWithNoTests 2>&1 | tail -20That runs the suite after every edit so failures surface immediately. Cursor also has a stop hook event.
Cursor Agent TDD
Write a failing test for a `parseCSV(input: string): Row[]` function,
then implement it. Use the test output to guide your implementation.
All edge cases (empty string, quoted commas, newlines in fields)
must be covered.
Jest Configuration for AI TDD
// jest.config.mjs — optimized for AI feedback loops
export default {
testEnvironment: 'node',
// Short output — AI reads terminal output, keep it concise
verbose: false,
// Fail fast: stop after 3 failing test suites (files) instead of running them all
bail: 3,
// Clear mocks between tests to avoid state bleed
clearMocks: true,
// Coverage thresholds to enforce quality
coverageThreshold: {
global: { branches: 80, functions: 90, lines: 90 },
},
};Antipatterns to Avoid
| Antipattern | Why it breaks AI TDD |
|---|---|
| Writing tests after implementation | Removes the specification value; agent writes tests to match its own code |
Letting agent mark tests as .skip | Agent "cheats" to make the suite pass |
| Huge test files | Agent loses context; split into focused files per module |
No bail config | Agent runs 200 failing tests on every iteration, burns tokens |
| Testing implementation details | Tests break on refactor; prefer behavior-based assertions |