NEW: Claude Code Security — research preview →

Test-Driven Development with AI

Write failing tests first, then let Claude Code, Copilot, or Cursor implement — the AI TDD feedback loop

Read time: 14 min

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 PostToolUse hook runs the suite after every file edit.
  • The Stop hook 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 (set CLAUDE_CODE_STOP_HOOK_BLOCK_CAP to raise the cap). The hook's input includes stop_hook_active, which is true when 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 -20

That 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

AntipatternWhy it breaks AI TDD
Writing tests after implementationRemoves the specification value; agent writes tests to match its own code
Letting agent mark tests as .skipAgent "cheats" to make the suite pass
Huge test filesAgent loses context; split into focused files per module
No bail configAgent runs 200 failing tests on every iteration, burns tokens
Testing implementation detailsTests break on refactor; prefer behavior-based assertions

Checklist