Let the tests keep the AI honest.

Red, green, refactor with an agent, a worked example you can run, and the checks that stop an agent from editing tests to pass.

A glass laboratory flask with a stopper
Photo by Raghav Bhasin on Unsplashdithered by Cyborb

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.

The short version
  • 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

  1. 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.

  2. 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.

  3. Lock the tests

    Commit them and tag the commit. From here on, any change to a test file shows up in a git diff against that tag, even if the agent commits it.

  4. Green: code until it passes

    The agent writes the simplest code that passes every test, without editing any of them.

  5. 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.

Setup
mkdir split-demo && cd split-demo
git init
echo "node_modules" > .gitignore
npm init -y
npm install --save-dev vitest fast-check

First, the red step. You list the behaviors; the agent turns them into tests:

PromptRed: tests only
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.
split.test.ts
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);
  });
});
split.ts (the stub)
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:

Output (trimmed)
 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:

Terminal
git add -A
git commit -m "test: specify splitCents"
git tag tests-locked

Now suppose the agent’s first draft divides and rounds:

A plausible first draft
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:

Output (trimmed)
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:

split.ts
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:

Terminal
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:

PromptGreen: code against locked tests
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 diff check 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 from toEqual to toBeDefined, or an if that 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:

split.property.test.ts
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:

Output against the rounding draft (trimmed)
Error: Property failed after 1 tests
Counterexample: [1,2]
Caused by: AssertionError: expected 2 to be 1 // Object.is equality

Properties 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:

Before you lock the tests0 of 6

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.

Sources
  1. Canon TDD, Kent Beck, December 2023
  2. Recent frontier models are reward hacking, METR, June 2025
  3. Best practices for Claude Code, Anthropic
  4. Getting started, Vitest
  5. Introduction, fast-check
  6. Welcome to Hypothesis, Hypothesis
cyborb.ai

Stop reading about it. Build it.

Describe what you want in plain words. Cyborb plans the work, writes and runs the code, makes the assets, and puts the result online.

Download Cyborb

Free to start. No card required.