An agent that writes code and also decides when it’s done will decide it’s done too early. Test-driven development fixes that: the test is written first, it fails on purpose, and nothing is finished until the suite is green. This lab runs that loop end to end on a small FastAPI endpoint and a React component, then automates it with a hook and a coverage gate so the loop survives after you close the chat.
Why TDD is the control loop an agent actually respects
A prompt like “add input validation” has no stopping condition — the agent can add one check and call it done. A failing test has a stopping condition built in: the process exit code. Pytest and the Vitest runner both exit non-zero on failure, so a hook or a CI job can check “did this pass” without a human reading the diff first.
That’s the value of writing the test before the code: the contract is written in a form the agent cannot argue with. It can disagree with your prose; it cannot disagree with a non-zero exit code it produced itself. That’s the same idea behind writing specs before code in spec-driven development — a test is a spec that executes.
The lab setup
Two small pieces: a FastAPI endpoint that applies a coupon to a subtotal, and a React component that renders the result. Scaffold it yourself (fastapi, pytest, pytest-cov; vitest, @testing-library/react) — the point of this lab is the loop, not the scaffold.
app/
main.py
pricing.py
tests/
test_pricing.py
src/
PriceSummary.tsx
PriceSummary.test.tsx
Step 1 — Write the failing test first, and forbid the implementation
Open a session in Claude Code, Cursor Agent, or Copilot’s agent mode and give it a prompt that names the behavior and explicitly blocks it from touching anything but the test file:
Write pytest tests in tests/test_pricing.py for a POST /pricing/total
endpoint. Rules for this request:
- a subtotal of 100 with coupon "SAVE10" returns total 90
- an unknown coupon is ignored and returns the original subtotal
- a negative subtotal returns HTTP 422
Do not create or edit app/main.py or app/pricing.py. Only write the test file.
Use FastAPI's TestClient.
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_known_coupon_applies_ten_percent_discount():
response = client.post("/pricing/total", json={"subtotal": 100.0, "coupon": "SAVE10"})
assert response.status_code == 200
assert response.json() == {"total": 90.0}
def test_unknown_coupon_is_ignored():
response = client.post("/pricing/total", json={"subtotal": 100.0, "coupon": "FAKE"})
assert response.json() == {"total": 100.0}
def test_negative_subtotal_is_rejected():
response = client.post("/pricing/total", json={"subtotal": -5.0, "coupon": None})
assert response.status_code == 422
The “do not create or edit” line matters more than it looks: without it, an agent asked for “tests for X” often writes a stub implementation in the same pass, erasing the RED step you need as evidence the test is real.
Step 2 — Run it and confirm RED
pytest -q
-q (or --quiet) is pytest’s documented flag for brief output. Run it before any implementation exists — you should see an import error or failing assertions, not a pass. A green suite at this point means the test isn’t testing anything yet.
Step 3 — Ask for the minimal implementation (GREEN)
Now hand the same test file back and ask for the smallest change that satisfies it:
tests/test_pricing.py is fixed and must not change. Write app/pricing.py
and app/main.py with the minimum code needed to make every test in that
file pass. Do not add features the tests do not check.
# app/pricing.py
COUPONS = {"SAVE10": 0.10}
def discounted_total(subtotal: float, coupon: str | None) -> float:
discount = COUPONS.get(coupon, 0.0) if coupon else 0.0
return round(subtotal * (1 - discount), 2)
# app/main.py
from fastapi import FastAPI
from pydantic import BaseModel, Field
from app.pricing import discounted_total
app = FastAPI()
class PricingRequest(BaseModel):
subtotal: float = Field(ge=0)
coupon: str | None = None
class PricingResponse(BaseModel):
total: float
@app.post("/pricing/total", response_model=PricingResponse)
def calculate_total(payload: PricingRequest) -> PricingResponse:
return PricingResponse(total=discounted_total(payload.subtotal, payload.coupon))
Field(ge=0) turns a negative subtotal into a 422 before your function runs — the test for that case is really testing the schema, not discounted_total. Run pytest -q again: three passes, zero failures, is GREEN.
Step 4 — Refactor with the tests as the safety net
Refactoring under TDD means changing the shape of the code, not the behavior the tests check:
Refactor discounted_total and calculate_total for readability. Do not
change any public behavior the tests in tests/test_pricing.py check.
Run pytest after the change and show me the result.
A reasonable refactor here is extracting the coupon lookup so a second endpoint can reuse it. Re-run the suite after every step, not just at the end — a break found immediately is far cheaper than one found after three more changes stack on top.
Step 5 — Automate the loop: run tests after every edit
Doing steps 2 and 4 by hand works for a demo. For real work, wire the test run into the tool so a red suite is visible the moment a file is written, not five prompts later.
In Claude Code, a PostToolUse hook matched on the Edit and Write tools reads tool_input.file_path from stdin and reruns the relevant suite — the same mechanism covered in the Claude Code hooks guide:
{
"hooks": {
"PostToolUse": [
{
"matcher": "^Edit$|^Write$",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-suite.sh", "timeout": 120 }
]
}
]
}
}
#!/bin/bash
# .claude/hooks/run-suite.sh — run the matching suite for the edited file.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
case "$path" in
*app/*.py|*tests/*.py) pytest -q >&2 || exit 2 ;;
*src/*.tsx|*.test.tsx) npm test -- --run >&2 || exit 2 ;;
esac
exit 0
Exit 2 on PostToolUse cannot undo the edit — it puts the failure on stderr so Claude reads it next turn. That’s the right place for “tests failed, keep working,” not for preventing a write; use PreToolUse for that.
Cursor’s equivalent is afterFileEdit in .cursor/hooks.json, project- or user-level, documented with its own file_path field on stdin and a matcher tested against the value Write:
{
"version": 1,
"hooks": {
"afterFileEdit": [
{ "command": ".cursor/hooks/run-suite.sh", "matcher": "Write" }
]
}
}
The script is the same shell logic. One real difference: Cursor’s documented afterFileEdit output has no schema for feeding results back into the conversation, so point it at a log file you check, or pair it with an always-applied Cursor rule — a .mdc file in .cursor/rules — that tells Agent to run the suite itself and not report a task finished while it fails. Pick Claude Code, Cursor, or Copilot based on which one you already run daily — the hook mechanics differ, the discipline they enforce does not.
Step 6 — Automated code review before the PR
Green tests prove the contract holds; they don’t catch a hardcoded secret, an N+1 query, or one function doing three unrelated things. Run a review step before opening the PR. In Claude Code that’s a subagent — a Markdown file in .claude/agents/ with its own tools and model, called by name or auto-delegated, as covered in the subagents guide:
---
name: code-reviewer
description: Reviews a diff for bugs, missing tests, and scope creep. Use before opening a PR.
tools: Read, Grep, Glob
---
Review the current diff against tests/test_pricing.py and PriceSummary.test.tsx.
Flag any change to a test file, any new untested branch, and any function over 40 lines.
Keep review and implementation as separate turns with separate context — a reviewer sharing the implementer’s blind spots misses the same bug twice.
Step 7 — a coverage gate in CI
A hook you can skip locally is not a gate. Put the real enforcement in CI, where a failing threshold blocks the merge regardless of what ran on a laptop. On the Python side, pytest-cov‘s --cov-fail-under fails the run when total coverage drops below the number you set:
pytest --cov=app --cov-report=term-missing --cov-fail-under=80
On the frontend, Vitest’s coverage.thresholds does the same per metric — lines, functions, branches, statements — either in config or as CLI flags, and vitest run (the non-watch invocation Vitest also falls back to automatically in CI) is what you want in a pipeline, not the default watch mode:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 },
},
},
});
npx vitest run --coverage
Both commands exit non-zero below threshold, which is all CI needs to fail the check. Who is allowed to merge past that gate is a policy question covered in the AI governance checklist.
Debugging: give the agent the failure, not a complaint
“It’s broken” sends the agent hunting. A stack trace plus the failing test gives it a starting point and a stopping condition in one message:
tests/test_pricing.py::test_unknown_coupon_is_ignored is failing:
AssertionError: assert {'total': 90.0} == {'total': 100.0}
Fix app/pricing.py so this test passes. Do not change the test.
The same works for a React failure: paste the Vitest assertion diff and the component name, and keep the test file out of scope in the same sentence.
Refactoring prompts that keep the agent inside the tests
Name the test files that must stay green, and name what must not change:
Extract the coupon lookup in app/pricing.py into its own module.
tests/test_pricing.py must still pass unmodified. Public function
signatures in app/main.py stay the same.
A prompt without a named test file invites the agent to “improve” the tests along with the code — the exact shortcut you’re trying to prevent.
Pitfalls
- The agent rewrites the test to make it pass. Diff the test file on every implementation turn; if it changed, reject the response and resubmit with the test frozen.
- Mocking everything. A test that mocks the database, the HTTP client, and the clock proves the mocks were called, not that the feature works. Keep one test per feature that exercises real code paths.
- Flaky tests get silenced instead of fixed. A hook that retries a flaky test until it’s green teaches the agent that red is temporary. Quarantine it, fix the root cause, then bring it back.
- Over-trusting green. A passing suite only proves the assertions you wrote hold, nothing about behavior nobody tested. Treat green as “this contract holds” and keep adding contracts.
- One happy-path test, called “done.” An agent under time pressure writes the minimum test that makes the feature look covered. Ask explicitly for the failure case and the boundary case too.
FAQ
Why is TDD a good fit for a coding agent specifically?
A failing test is a machine-checkable definition of done. The agent cannot talk its way past a red test the way it can talk its way past a vague prompt. The test is the contract; the agent’s job is to make it pass without weakening it.
How do I stop the agent from editing the test to make it pass?
Write and commit the test before asking for any implementation, then tell the agent the test file is frozen. Diff review matters more than the prompt: if the implementation turn touches the test file at all, reject it and resubmit with the test explicitly out of scope.
What actually runs after each edit, a Claude Code hook or a Cursor hook?
Claude Code runs a PostToolUse hook matched on the Edit and Write tools, reading tool_input.file_path from stdin. Cursor runs an afterFileEdit hook from .cursor/hooks.json, reading file_path from stdin, matched against the value Write. Both call out to pytest or npm test; neither hook can undo the edit that already landed.
Does a green test suite mean the code is correct?
No. Green only means the assertions you wrote hold for the inputs you wrote. An agent that mocks every collaborator, or writes one happy-path test, can be green and still ship a broken edge case. Treat green as “the contract I specified is met,” and keep adding contracts.
What’s the minimum coverage gate worth setting up?
On Python, pytest --cov=app --cov-fail-under=80 fails the run below 80% total coverage. On Vitest, set coverage.thresholds.lines, .functions, .branches, and .statements in config, or pass the same as --coverage.thresholds.* CLI flags. Put both in CI, not just a local hook, so a skipped hook can’t merge uncovered code.
How this guide was verified
Written by hand from the official documentation, every command re-checked, and reviewed a second time before publishing. Last reviewed on .
Official sources read for this article

