TDD with AI means you agree on the tests first, lock them in a commit, and only then let the agent write code until they pass. The tests become the spec. The agent cannot call the work done while any test is red, and you can check that claim in seconds.
This guide covers the loop, a worked example you can run, how to stop an agent from editing tests, and where property-based tests catch what examples miss.
- Write the tests first, watch them fail for the right reason, then commit them.
- Let the agent write code until the tests pass, without touching the test files.
- An instruction is not a lock. Check with Git that the tests did not change.
- Property-based tests check a rule against many random inputs, which an agent cannot special-case.
- Review the tests harder than the code, because they are the spec.
Why TDD works so well with coding agents
Agents stop when the work looks done. A failing test is a “not done” signal that does not depend on the agent’s judgment, so it keeps going until the test passes.
Tests are also a sharper spec than prose. “Handle the leftover cents” can mean several things. expect(splitCents(1000, 3)).toEqual([334, 333, 333]) means exactly one.
Tests also make review cheap: twenty lines of tests are faster to read than two hundred lines of code.
There is one catch. The easiest way to turn a red test green is to change the test, and agents sometimes take that path. In June 2025, the evaluation group METR reported frontier models modifying tests or scoring code to raise their scores. In one test of OpenAI’s o3, adding “Please do not cheat.” to the prompt still left 80% of runs cheating.
Red, green, refactor with an agent
Agree on the list
Ask the agent to propose test cases: normal cases, edge cases and invalid input. Then cut, add and correct them. This is where your knowledge of the problem matters most.
Red: tests only, and watch them fail
The agent writes the tests and an empty stub, then runs them. Every test should fail for the right reason: a wrong result or a missing error, not a typo or a broken import.
Lock the tests
Commit them and tag the commit. From here on, any change to a test file shows up in a
git diffagainst that tag, even if the agent commits it.Green: code until it passes
The agent writes the simplest code that passes every test, without editing any of them.
Refactor
With everything green, clean up names and structure. The tests must stay green and unchanged. For code with few tests to begin with, our guide to refactoring legacy code with AI shows how to add them safely first.
Kent Beck’s canonical TDD adds one test at a time. With an agent, a small batch of tests for one function is a fair trade: you review the whole spec in one pass. For a bug fix, the first test reproduces the bug, which is also where debugging with AI should start.
A worked example: splitting a bill in TypeScript
Here is the loop on a small function with real edge cases: split an amount of money between people. Amounts are whole cents, because floating-point numbers cannot store most decimal fractions exactly. The rules: shares add up to the total, no two shares differ by more than a cent, and bad input throws an error.
As of September 2026, Vitest 5 needs Node.js 22.12 or newer.
mkdir split-demo && cd split-demo
git init
echo "node_modules" > .gitignore
npm init -y
npm install --save-dev vitest fast-checkFirst, the red step. You list the behaviors; the agent turns them into tests:
We are doing test-driven development. In split.test.ts, write Vitest tests for splitCents(totalCents, people), which splits an amount in cents between people. Cover: an even split, leftover cents going to the first people, shares always adding up to the total, zero people, and fractions of a cent (both must throw a RangeError). Create split.ts with a stub that throws "not implemented". Do not implement anything. Run npx vitest run and show me that every test fails, and why.
import { describe, expect, test } from "vitest";
import { splitCents } from "./split";
describe("splitCents", () => {
test("splits evenly when the amount divides", () => {
expect(splitCents(900, 3)).toEqual([300, 300, 300]);
});
test("gives leftover cents to the first people", () => {
expect(splitCents(1000, 3)).toEqual([334, 333, 333]);
});
test("always adds up to the original total", () => {
const parts = splitCents(10, 4);
expect(parts.reduce((sum, p) => sum + p, 0)).toBe(10);
});
test("rejects a split between zero people", () => {
expect(() => splitCents(1000, 0)).toThrow(RangeError);
});
test("rejects fractions of a cent", () => {
expect(() => splitCents(10.5, 2)).toThrow(RangeError);
});
});export function splitCents(totalCents: number, people: number): number[] {
throw new Error("not implemented");
}Run npx vitest run and all five tests fail. The two error tests fail because the stub throws a plain Error instead of a RangeError, which proves they check the right thing:
FAIL split.test.ts > splitCents > splits evenly when the amount divides
Error: not implemented
FAIL split.test.ts > splitCents > rejects a split between zero people
AssertionError: expected error to be instance of RangeError
Tests 5 failed (5)Read the tests, then lock them:
git add -A
git commit -m "test: specify splitCents"
git tag tests-lockedNow suppose the agent’s first draft divides and rounds:
export function splitCents(totalCents: number, people: number): number[] {
const share = Math.round(totalCents / people);
return Array(people).fill(share);
}It reads fine, and it even works for 900 split three ways. The tests disagree:
AssertionError: expected [ 333, 333, 333 ] to deeply equal [ 334, 333, 333 ]
AssertionError: expected 12 to be 10 // Object.is equality
Tests 4 failed | 1 passed (5)Ten cents split four ways came to twelve. This is the dangerous kind of wrong: it survives a quick review and fails only for some amounts. A version that passes gives the leftover cents out one at a time:
export function splitCents(totalCents: number, people: number): number[] {
if (!Number.isSafeInteger(totalCents) || totalCents < 0) {
throw new RangeError("totalCents must be a whole, non-negative number");
}
if (!Number.isSafeInteger(people) || people < 1) {
throw new RangeError("people must be a whole number, at least 1");
}
const base = Math.floor(totalCents / people);
const leftover = totalCents % people;
return Array.from({ length: people }, (_, i) => (i < leftover ? base + 1 : base));
}All five tests pass. Before you accept the green, confirm the tests are the ones you committed:
npx vitest run
git diff --exit-code tests-locked -- '*.test.ts' && echo "tests untouched"How do you stop an AI from editing tests to pass?
Treat it as layers, from a polite request to a hard check. Start with the prompt for the green step:
The tests in split.test.ts are committed and final. Implement splitCents in split.ts so that they all pass. Do not edit, skip or delete any test, and do not special-case the inputs the tests use. If a test looks wrong, stop and explain why instead of changing it. When you are done, run npx vitest run and paste the summary.
Then back the request with checks the agent cannot talk its way around:
Commit and tag the tests first. The
git diffcheck above compares every test file with the tagged commit, so it catches an edit even after the agent commits it. The'*.test.ts'pattern covers every folder.Run the check after every turn, and again in CI before merge.
Block writes if your agent allows it. Some agents support permission rules or hooks that can deny edits to a folder. Use them on the test folder during the green step.
Watch for softer cheats: a test marked
skip, an assertion loosened fromtoEqualtotoBeDefined, or anifthat returns the exact value a test expects.
Our guide to reviewing AI-written code covers the test diff in more detail.
Where property-based tests help
Example tests check five cases. An agent could, in theory, special-case all five. It cannot special-case a rule checked against random inputs on every run. Here is the bill-splitting rule, written with fast-check:
import fc from "fast-check";
import { expect, test } from "vitest";
import { splitCents } from "./split";
test("every split adds up, and no two shares differ by more than a cent", () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 1_000_000_000 }),
fc.integer({ min: 1, max: 1_000 }),
(total, people) => {
const parts = splitCents(total, people);
expect(parts).toHaveLength(people);
expect(parts.reduce((sum, p) => sum + p, 0)).toBe(total);
expect(Math.max(...parts) - Math.min(...parts)).toBeLessThanOrEqual(1);
},
),
);
});Run it against the rounding draft and it fails almost at once. fast-check then shrinks the failure, usually down to one cent split between two people:
Error: Property failed after 1 tests
Counterexample: [1,2]
Caused by: AssertionError: expected 2 to be 1 // Object.is equalityProperties shine for money, dates, parsing, sorting and anything with a round trip, such as encode then decode. In Python, pytest with the Hypothesis library does the same job through its @given decorator.
What to check in tests an agent wrote
The agent can write most of the tests, but you approve them. Review the test file before any code exists:
The second item deserves a second look. Pasting whatever the code returns into the expected value turns a test into a recording of the bug. Kent Beck lists it among the classic TDD mistakes.
FAQ
Is TDD with AI worth the extra step?
Usually, yes. The agent writes most of the tests, so your cost is reading them. In return, “done” becomes something you can verify, and almost-right code gets caught before it ships.
Should the same agent write the tests and the code?
It can, as long as you review the tests and commit them before any code exists. For important code, use a fresh session for the implementation, so it works from the tests rather than from its own earlier reasoning.
What if a test turns out to be wrong?
Stop the green step. Fix the test yourself, or approve the agent’s proposed change, in its own commit with a reason. Never let a test change ride along with the code that makes it pass.
Does TDD work for user interface code?
For logic, state and data handling, yes. For how a screen looks, tests are a weak fit. Use screenshots compared against a design, or a quick look by a person.
Next, fit this into a daily AI pair programming workflow, or copy the tests-first prompt and eleven others from our prompts for coding agents.
- Canon TDD, Kent Beck, December 2023
- Recent frontier models are reward hacking, METR, June 2025
- Best practices for Claude Code, Anthropic
- Getting started, Vitest
- Introduction, fast-check
- Welcome to Hypothesis, Hypothesis




