Skip to content

Test generation and TDD with Claude Code

Test generation with Claude Code works when the test is the contract: Claude Code copies the patterns of existing tests, writes a failing test first, implements with the test file locked, and reruns the suite through hooks after every edit. Mutation checks and CI then prove the suite catches bugs, without anyone reading every generated line.

Your checkout route passes every unit test, the coverage badge says 94%, and it still returns 500s in production, because nothing ever exercised the path where the payment provider succeeds and the database write fails. The tests were green because they asserted that mocks were called. Now your tech lead asks for tests on the next feature, and you want a suite that fails for the right reasons instead of a wall of green checkmarks.

This page is for developers who run Claude Code in the terminal and for tech leads who want the same test discipline on every branch. It assumes Claude Code is installed and configured; if not, start with the Claude Code test and debug overview and project configuration.

What you’ll walk away with from Claude Code test generation

Section titled “What you’ll walk away with from Claude Code test generation”
  • A hook pair that runs the related tests after every edit and refuses to let Claude finish on a red suite.
  • A red-green-refactor loop in which the implementing session cannot edit the test file.
  • Copy-paste prompts for pattern-matched tests, integration tests at the seams, and flake-resistant Playwright tests.
  • A mutation check that tells you whether a generated suite catches bugs, without reading it line by line.
  • A /test-route skill and a headless CI triage job you can commit today.

Who checks what when Claude Code writes the tests?

Section titled “Who checks what when Claude Code writes the tests?”

The shift is that you approve behaviour, not lines. Split the work so every generated artifact has a check that is not the model’s own opinion.

ArtifactWho produces itHow it is verifiedWho signs off
List of behaviours to testYou, or Claude from the specYou read the list of test names (10–20 lines, not the code)You
Failing testClaudeIt runs red for the stated reason before any implementation existsHook output shows it
ImplementationClaude, with tests lockedThe suite goes green; the test file is unchanged in the diffCI
Suite strengthClaude, on requestMutation check: injected bugs must turn the suite redReviewer reads the survivor list
MergeCIType check, lint, full suite, coverage on changed filesCode owner reviews the evidence, not every line

Where the acceptance criteria come from before this loop starts is covered in executable acceptance criteria. What a pull request has to attach as proof is covered in the evidence bundle.

Set up the test feedback loop before you generate anything

Section titled “Set up the test feedback loop before you generate anything”

Claude Code writes better tests when the test command is in its instructions and the results come back to it automatically. Do this once per repository.

  1. Put the exact test commands in CLAUDE.md. Claude uses what it finds there instead of guessing between npm test, npx vitest and a Makefile target.

    ## Tests
    - Run one file: `npx vitest run src/routes/orders.test.ts`
    - Run tests related to a changed file: `npx vitest related src/routes/orders.ts --run`
    - Full suite: `npx vitest run`
    - Tests live next to the code as `*.test.ts`. Never edit a test to make it pass.
  2. Add a PostToolUse hook that runs the related tests after each edit. A PostToolUse hook’s standard output is not shown to Claude; exit code 2 is what sends its standard error back into the conversation. Save this as .claude/hooks/test-related.sh and make it executable (chmod +x):

    #!/usr/bin/env bash
    # PostToolUse: run the tests that cover the file Claude just edited
    file=$(jq -r '.tool_input.file_path // empty')
    case "$file" in
    *.ts|*.tsx) ;;
    *) exit 0 ;;
    esac
    if ! out=$(npx vitest related "$file" --run 2>&1); then
    echo "$out" | tail -40 >&2
    exit 2
    fi
  3. Add a Stop hook that runs the full suite before Claude reports done. Exit code 2 on a Stop hook keeps the conversation going with the failure as feedback. Save this as .claude/hooks/suite-before-stop.sh:

    #!/usr/bin/env bash
    # Stop: do not let Claude finish while the suite is red
    if ! out=$(npx vitest run 2>&1); then
    echo "The test suite is failing. Fix the code, not the tests:" >&2
    echo "$out" | tail -60 >&2
    exit 2
    fi
  4. Register both hooks in .claude/settings.json and commit the file and the scripts so the whole team gets the same loop:

    {
    "hooks": {
    "PostToolUse": [
    {
    "matcher": "Edit|Write",
    "hooks": [
    { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/test-related.sh" }
    ]
    }
    ],
    "Stop": [
    {
    "hooks": [
    { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/suite-before-stop.sh" }
    ]
    }
    ]
    }
    }

Generate tests that match your existing patterns

Section titled “Generate tests that match your existing patterns”

The most common complaint about generated tests is style drift: a different mock approach, different naming, a new fixture helper that duplicates one you already have. The fix is to make Claude read two good existing test files before it writes anything, and to name the unhappy paths it must cover.

The last paragraph is the part that saves review time. You approve a list of 10–20 behaviours, which takes a minute, instead of reading 300 lines of test code. If a behaviour you care about is missing from the list, add it before a line is written.

For fixtures, point Claude at your schema and an existing factory (“follow the pattern of createTestUser() in tests/fixtures/user.fixtures.ts”) and ask for edge-case variants such as an organization at its member limit or with an expired trial. Test data management covers seeding and isolation in depth.

Run red-green-refactor with the test file locked

Section titled “Run red-green-refactor with the test file locked”

A test that has never been red is not a test. The loop below makes the red step observable and makes it much harder for the implementing session to “fix” a failure by editing the assertion, which is the most common way agent-written suites lie. Edit deny rules cover the file tools and shell redirections, not a script that writes files itself; the git diff check in step 4 catches the rest.

The running example is an Express route that charges a payment and then writes the order with Drizzle ORM. It has the two things that break in production: an external call and a database write that can fail after it.

src/routes/orders.ts
import { Router } from 'express';
import { db } from '../db';
import { orders } from '../db/schema';
import { charge, PaymentError } from '../lib/payments';
export const ordersRouter = Router();
ordersRouter.post('/orders', async (req, res) => {
const { userId, amountCents, idempotencyKey } = req.body;
if (!userId || !amountCents) {
return res.status(400).json({ error: 'userId and amountCents required' });
}
try {
const payment = await charge({ amountCents, idempotencyKey });
const [order] = await db
.insert(orders)
.values({ userId, amountCents, paymentId: payment.id, status: 'paid' })
.returning();
return res.status(201).json(order);
} catch (err) {
if (err instanceof PaymentError) return res.status(402).json({ error: 'payment_failed' });
// The charge succeeded but the write failed: the dangerous case
return res.status(503).json({ error: 'order_persist_failed', retryable: true });
}
});
  1. Write the failing test in a session that can edit only test files. Run the red step headlessly with --permission-mode dontAsk, which denies every tool call not on the allowlist. Edit(...) path rules govern every file-writing tool, including Write, so this session can create and change *.test.ts files under src/ and nothing else.

    Terminal window
    claude -p "Write ONE Vitest + supertest test in src/routes/orders.test.ts:
    a second POST /orders with the same idempotencyKey must not call charge()
    again. Mock only the payment provider and the Drizzle insert. Do not touch
    the route. Run the test and show me the failure output." \
    --permission-mode dontAsk \
    --allowedTools "Read" "Grep" "Glob" "Edit(src/**/*.test.ts)" "Bash(npx vitest *)"
  2. Confirm it fails for the right reason. The failure must be an assertion (charge was called twice), not a missing import or a typo. The route above passes the key to the provider but never checks for an existing order, so this test is red for a real gap.

  3. Implement in a session that cannot edit tests. Start an interactive session with a deny rule on test files; a deny rule wins over any allow rule.

    Terminal window
    claude --disallowedTools "Edit(**/*.test.ts)"

    Then prompt: “Make src/routes/orders.test.ts pass with the smallest change to src/routes/orders.ts. You cannot edit test files. If you think the test is wrong, stop and tell me why.”

  4. Check that the test file is unchanged. git diff --stat should list only the route. If you use the hooks above, the Stop hook has already run the full suite.

  5. Refactor under a green bar. Ask for the refactor you want (“extract the idempotency lookup into findOrderByKey”); the PostToolUse hook runs the related tests after every edit.

The last instruction matters: a test that is green on its first run either documents behaviour that already exists or asserts nothing. Either way, you want to know. The cross-tool version of this loop, with the reasoning behind each step, is on test-driven development with AI assistance. Locking tests at team scale, with CODEOWNERS and CI-owned checks, is on protecting the oracle.

Write integration tests at the seams, not against your mocks

Section titled “Write integration tests at the seams, not against your mocks”

Over-mocked suites pass because they never touch the boundary where production fails. Mock only the true external edges (the payment provider’s HTTP call, the database driver’s failure behaviour) and run everything in between for real.

A good result asserts behaviour (status, body, call count) and never implementation details:

import request from 'supertest';
import { beforeEach, expect, it, vi } from 'vitest';
import { app } from '../app';
import { db } from '../db';
import { charge } from '../lib/payments';
vi.mock('../lib/payments', async (importOriginal) => ({
...(await importOriginal<typeof import('../lib/payments')>()),
charge: vi.fn(),
}));
beforeEach(() => {
vi.mocked(charge).mockReset();
vi.restoreAllMocks();
});
it('returns a retryable 503 when the insert fails after a charge', async () => {
vi.mocked(charge).mockResolvedValue({ id: 'pay_123' });
vi.spyOn(db, 'insert').mockImplementation(() => {
throw new Error('connection terminated');
});
const res = await request(app)
.post('/orders')
.send({ userId: 'u1', amountCents: 4999, idempotencyKey: 'k1' });
expect(res.status).toBe(503);
expect(res.body).toMatchObject({ retryable: true });
expect(charge).toHaveBeenCalledTimes(1);
});

If you run integration tests against a real database instead of a mocked insert, connect a database MCP server so Claude reads the real schema and constraints instead of guessing them; database MCP servers lists the maintained options. Integration test patterns covers containers, transactions and contract tests across all three tools.

End-to-end tests flake when the test races the application: a click before the button is interactive, an assertion before the network call resolves, a fixed wait that works on a laptop and fails on a slower runner. Give Claude a browser so it reads the real DOM instead of inventing selectors. The Playwright MCP server (@playwright/mcp, 0.0.82 on npm on 2026-09-26) adds one:

Terminal window
claude mcp add playwright -- npx @playwright/mcp@latest

--repeat-each=3 turns a one-shot generation into a stability check before the test reaches your pipeline. For checking a change in the running app without a test file at all, the bundled /verify skill builds and drives the app; agent-browser and browser MCP servers compare the other options.

How do you know the generated tests catch bugs?

Section titled “How do you know the generated tests catch bugs?”

Line coverage measures which lines ran, not whether anything checked them. You can prove a suite’s strength without reading it by asking whether it catches bugs you plant on purpose.

Commit or stash your own changes to the route before you run this prompt, so a stray revert cannot take real work with it.

A surviving bug is a missing assertion, and the survivor table is the artifact a reviewer reads. For a repeatable score instead of a prompt, run a mutation tool in CI: Stryker (@stryker-mutator/core with @stryker-mutator/vitest-runner, both 10.0.0 on npm on 2026-09-26) mutates the code automatically and reports a mutation score. How strong is your oracle? sets the thresholds a suite should clear before you let a loop run unattended.

Three cheaper checks catch most bad suites before a mutation run:

  • Red-first evidence. Every new test was seen failing, with the output in the session or the pull request.
  • Stability. New tests pass three runs in a row: rerun the file three times, or use Playwright’s --repeat-each=3.
  • A second reader that did not write the tests. Ask a fresh subagent: “Review the tests added on this branch. For each one, state the behaviour it verifies in one sentence. Flag any test that would still pass if the function under test returned a constant, and any assertion on internal calls rather than outputs.” A fresh context has no stake in the tests it reviews.

Custom commands are now skills in Claude Code: a file at .claude/skills/<name>/SKILL.md creates /<name>, and existing .claude/commands/ files keep working. Name the stack and the mandatory cases inside the skill, so every call produces the same test plan instead of “comprehensive tests”.

---
name: test-route
description: Write Vitest + supertest integration tests for one Express route, including failure and idempotency cases.
argument-hint: "[METHOD /path]"
disable-model-invocation: true
allowed-tools: Read Grep Glob Bash(npx vitest *)
---
Write integration tests for the Express route: $ARGUMENTS
Stack: Express, Drizzle ORM (Postgres), Vitest, supertest.
Run the real middleware and handler. Mock only external services and the DB.
First list the test names and wait for approval. Always include, one it() each:
- happy path (correct status and persisted row)
- input validation (400 on missing required fields)
- external dependency throws (the mapped error status, for example 402)
- DB write fails AFTER an external side effect (a retryable 5xx)
- idempotency: a repeated request with the same key has no second effect
Assert status codes and specific body fields. No snapshot tests.
Reset mocks in beforeEach. Run the new file and show it red or green per case.
Never edit the route in this skill.

Invoke it with /test-route POST /orders. disable-model-invocation: true keeps Claude from triggering it on its own, and allowed-tools pre-approves only reads and the test runner for that turn. Custom commands and skills covers arguments and supporting files.

If you would rather install a maintained TDD skill than write one, two are widely used. Matt Pocock’s tdd skill and Superpowers’ test-driven-development skill both enforce red-green-refactor. On skills.sh they had about 967,000 and 237,000 all-time installs respectively, according to a 2026-09-26 snapshot published by LinklyAI/best-skills (a secondary source):

Terminal window
npx skills add mattpocock/skills --skill tdd -a claude-code
npx skills add obra/superpowers --skill test-driven-development -a claude-code

Each installed skill adds its description to every session’s context, so install the one you will use, not both. (claude plugin install mattpocock-skills installs Pocock’s whole engineering set instead of the single skill.)

The same discipline runs without you. In CI, call Claude only when the suite is red, and give it read access only, so it can diagnose the failure but cannot rewrite tests to make it green. The tests run in a separate job with no secret in the environment, and the agent job reads their output from an artifact on a fresh runner: a test step can write to GITHUB_ENV or GITHUB_PATH and so reach every later step of its own job. Never let the agent run the pull request’s own code while ANTHROPIC_API_KEY is set: a test file, a Vitest config or a project hook in the PR could read the key. --bare skips auto-discovery of hooks, plugins and CLAUDE.md, and expects MCP servers to be passed explicitly with --mcp-config. --restricted ignores the PR’s settings files (so its .claude/settings.json cannot grant itself permissions) and removes Bash and the other code-running tools (checked on v2.1.283). --setting-sources "" loads no settings file at all, and --strict-mcp-config is a belt-and-braces guard against the PR’s .mcp.json. --max-budget-usd caps the spend per run. Your normal test job stays the merge gate; this job only explains the failure. On the pull_request trigger GitHub runs this workflow file from the PR’s merge ref, so a pull request from a branch in your repository could edit it to expose the key (fork pull requests get no secrets). Put /.github/ in CODEOWNERS and require code owner review before you add the secret.

.github/workflows/test-triage.yml
name: test-triage
on: [pull_request]
permissions:
contents: read
jobs:
tests:
runs-on: ubuntu-latest
outputs:
outcome: ${{ steps.tests.outcome }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm ci
# The PR's own code (tests, vitest config) runs here, in a job with NO secret.
- id: tests
run: npx vitest run --reporter=verbose > test-output.txt 2>&1
continue-on-error: true
- uses: actions/upload-artifact@v7
with:
name: test-output
path: test-output.txt
triage:
needs: tests
if: needs.tests.outputs.outcome == 'failure'
runs-on: ubuntu-latest
steps:
# A fresh runner: nothing the PR's code wrote in the tests job reaches this one.
# Install before checkout, so a .npmrc in the PR cannot steer the install.
- uses: actions/setup-node@v7
with:
node-version: 22
- run: npm install -g @anthropic-ai/claude-code
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: test-output
# The agent only reads: no Bash, no project hooks, settings or MCP servers from the PR.
- name: Triage failing tests
run: |
claude -p "Read test-output.txt. Name each failing test, the likely
root cause and a one-line fix. Do not edit files." \
--bare --restricted --setting-sources "" --strict-mcp-config \
--permission-mode dontAsk \
--allowedTools "Read" "Grep" "Glob" \
--max-budget-usd 2 \
--output-format json > triage.json
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

To post the result as a pull request comment, or to let Claude open a follow-up pull request with missing tests, use the anthropics/claude-code-action@v1 GitHub Action; CI/CD integration has the full workflow. Keep test generation out of the merge gate itself: generated tests arrive as a pull request that goes through the same hooks, mutation check and review as any other change.

For a one-off backfill of missing test files on a branch, run the red-step permissions in a loop. The < /dev/null matters: claude -p reads piped stdin, so without it the first call swallows the rest of the file list and the loop backfills a single file.

Terminal window
git diff --name-only main...HEAD -- 'src/*.ts' ':!*.test.ts' |
while read -r file; do
test_file="${file%.ts}.test.ts"
[ -f "$test_file" ] && continue
claude -p "Read $file and two neighbouring *.test.ts files. Write $test_file
in the same style, covering every exported function and every thrown error.
Run it with npx vitest run $test_file and report the result." \
--permission-mode dontAsk \
--allowedTools "Read" "Grep" "Glob" "Edit(src/**/*.test.ts)" "Bash(npx vitest *)" \
< /dev/null
done

Tests written after the code describe what the code does, bugs included. Run the mutation check on the result before you trust it.

What breaks when Claude Code writes your tests?

Section titled “What breaks when Claude Code writes your tests?”

Claude “fixed” a failing test by weakening the assertion. Asked to make tests pass, an agent can edit the test instead of the code. Recovery: revert the test file with git checkout -- <file>, restart the implement session with --disallowedTools "Edit(**/*.test.ts)", and add a CI check that fails when a pull request changes both a test and the code it covers without a tests-changed label. Protecting the oracle has the full pattern.

The suite is green but production still breaks. You are testing mocks, not seams. Recovery: rerun the integration prompt and force real execution of everything except the true external boundary. If a mock and the real API can drift, add a contract test against the provider’s sandbox that runs nightly.

The hook runs but Claude never reacts to failures. A PostToolUse hook that exits 0 or 1 is invisible to Claude. Recovery: make the script print the failure to standard error and exit 2, as in the script above, and check the transcript for the hook feedback.

The Stop hook makes every turn slow. A full suite on every stop costs minutes. Recovery: point the Stop hook at the fast unit project, keep the PostToolUse hook on related tests only, and leave integration and end-to-end suites to CI.

End-to-end tests pass locally and flake in CI. Almost always a race. Recovery: search the file for waitForTimeout and delete every hit, replace each with a web-first assertion or toHaveURL, and rerun with --repeat-each=3.

Tests break on every safe refactor. They assert implementation. Recovery: “These tests break on safe refactors. Rewrite them to assert observable behaviour (inputs, outputs, status codes, persisted state), never private method names or call order unless the order is the contract.”

Tests pass alone and fail together. Shared mutable state. Recovery: “Each test must be independent. Find shared variables that tests modify, reset state in beforeEach, and wrap database tests in a transaction that rolls back.”

The headless run did nothing. In -p mode with dontAsk, any tool call outside the allowlist is denied without a prompt. Recovery: rerun with --output-format json and read the denied calls in the result, then widen the allowlist by the narrowest rule that covers them.

Using another tool on the same codebase? The equivalent pages are testing with Codex and TDD in Cursor.