Does TDD Still Matter When Agents Write the Code?
See how test-driven development keeps AI coding agents accountable: write Vitest specs first, prevent test tampering, and use CI and code review to verify results.
Yes, and arguably more than before, but as a contract rather than a ritual: when a coding agent writes the implementation, tests written first become the specification, defining “done” before any code exists and catching the agent when it reports a task complete that isn’t.
Most developers who hand implementation to Claude Code, Cursor or GitHub Copilot have seen it happen. The agent announces that everything passes, and then the feature breaks on first use, or a test file has been quietly rewritten so it agrees with the new code.
This article covers the test-first workflow for agents: a real Vitest file, the exact instruction to pair with it, the ways agents get around it, and the guardrails that close those gaps.
Key Takeaways
- Tests written before the agent starts act as an executable spec: the agent’s job is to make them pass, not to decide what passing means.
- A test written after the code tends to describe what the code already does, so it passes by construction and checks nothing.
- An agent’s message saying all tests pass is a claim. A CI run on the pushed commit is the evidence.
- If one diff changes both the implementation and the assertions that judge it, the agent has graded its own work, so review test changes separately from code changes.
- Skip test-first for throwaway prototypes and purely visual tweaks, where the expected result can’t be written down until you see it.
What Is Red-Green-Refactor?
Red-green-refactor is the TDD loop: you write a failing test that describes one behaviour (red), then write the minimum code that makes it pass (green). With the suite green, you clean up the code without changing what it does (refactor). For a longer refresher with React examples, see TDD vs BDD in React.
How Does the TDD with AI Workflow Work?
TDD with AI works when the human writes or approves the failing tests and the agent is told to make them pass without changing them. The tests hold the requirements. The agent only supplies the implementation. The examples below use Vitest 5.
Start with a stub, so the test file imports something real:
// src/cart/applyCoupon.ts
export type Coupon =
| { code: string; kind: 'percent'; value: number }
| { code: string; kind: 'fixed'; valueCents: number };
export function applyCoupon(
subtotalCents: number,
code: string,
coupons: Coupon[],
): number {
throw new Error('not implemented');
}
Then write the spec:
// src/cart/applyCoupon.test.ts
import { describe, it, expect } from 'vitest';
import { applyCoupon, type Coupon } from './applyCoupon';
const coupons: Coupon[] = [
{ code: 'SAVE10', kind: 'percent', value: 10 },
{ code: 'FIVEOFF', kind: 'fixed', valueCents: 500 },
];
describe('applyCoupon', () => {
it('takes a percentage off the subtotal', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
it('takes a fixed amount off the subtotal', () => {
expect(applyCoupon(2000, 'FIVEOFF', coupons)).toBe(1500);
});
it('never returns a negative total', () => {
expect(applyCoupon(300, 'FIVEOFF', coupons)).toBe(0);
});
it('matches codes case-insensitively', () => {
expect(applyCoupon(2000, 'save10', coupons)).toBe(1800);
});
it('rejects unknown codes', () => {
expect(() => applyCoupon(2000, 'BOGUS', coupons)).toThrow('Unknown coupon');
});
});
Because the stub exists and throws, all five tests fail at runtime rather than on a missing import. That is a clean red phase. The last test needs a closer look: the stub does throw, but its message doesn’t match 'Unknown coupon', so it still fails for the right reason. Each test pins one rule (percentage versus fixed discounts, the floor at zero, case handling) that an agent would otherwise have to guess.
Then hand it over with explicit constraints:
Implement applyCoupon in src/cart/applyCoupon.ts so every test in
src/cart/applyCoupon.test.ts passes.
Rules:
- Do not create, edit, rename, skip or delete any *.test.ts file.
- Do not add .skip, .only, or mocks of applyCoupon.
- If a test looks wrong, stop and explain why instead of changing it.
- When finished, run `npx vitest run src/cart` and paste the full output.
The “stop and explain” line gives the agent a legitimate way out other than changing the test. The pasted output saves you a step, but it doesn’t prove anything. More prompting habits that fit this loop are in 5 Tips and Tricks for AI-Assisted Coding.
How Does Test-First Go Wrong with Agents?
Agents tend to find the shortest route to a green run, and three routes skip the spec entirely.
Editing or deleting tests. When an implementation is hard, changing the expectation is easier than meeting it. A changed toBe(1800), a new .skip, or a test file that has disappeared all produce a green run with the requirement removed.
Weak assertions. A test written after the code tends to confirm what the code does, not what it should do:
// Confirms the code ran, not that it's right
it('applies coupon', () => {
const result = applyCoupon(2000, 'SAVE10', coupons);
expect(result).toBeDefined();
expect(typeof result).toBe('number');
});
// Pins the behaviour
it('takes 10% off with SAVE10', () => {
expect(applyCoupon(2000, 'SAVE10', coupons)).toBe(1800);
});
The weak toBeDefined() test passes if applyCoupon returns 0, 2000 or 999999. This is why the order of writing matters: tests the agent writes after its own code tend to look like the first version. Agents also pad suites with many shallow checks like this, a pattern covered in the smoke tests agents keep writing.
Mocking the thing under test. Mocking the module under test means the test exercises the mock, and that test stays green whatever the real implementation does:
import { vi } from 'vitest';
vi.mock('./applyCoupon', () => ({ applyCoupon: () => 1800 }));
// Every assertion now tests the mock.
Vitest hoists vi.mock above the imports, so the factory replaces the module before the real code loads. The real applyCoupon never runs. Mock the boundaries (network, storage, time), not the unit you are specifying.
Which Guardrails Keep the Spec Fixed?
Guardrails work when they are mechanical. Instructions reduce test tampering, and CI and review catch what instructions miss.
Review test diffs separately. Read changes under *.test.ts as their own review, before the implementation. On GitHub, CODEOWNERS with required code-owner review can make sure a named person signs off on any test path change.
Block or forbid test edits for the agent.
- Claude Code: add a
permissions.denyrule such as"Edit(**/*.test.ts)"to.claude/settings.json. Edit deny rules cover Claude’s built-in file tools, plus file commands it recognises in Bash (such assedandtee) and redirection targets. They don’t cover scripts or other subprocesses that write files indirectly. Treat the rule as friction, not enforcement. - Cursor: state the rule in project rules under
.cursor/rules. - GitHub Copilot: put it in
.github/copilot-instructions.md. Cursor and Copilot both also readAGENTS.md.
Let CI decide “done”. Run npx vitest run on the pushed commit. In CI, Vitest fails the run if it finds .only, unless you pass --allowOnly or set allowOnly: true. It doesn’t catch .skip, so add a diff guard on implementation branches:
changed=$(git diff --name-only origin/main...HEAD -- '*.test.ts' '*.spec.ts')
if [ -n "$changed" ]; then
echo "Test files changed on an implementation branch:"
echo "$changed"
exit 1
fi
The checkout needs enough history for origin/main to resolve.
Check that assertions test behaviour. For each assertion, ask whether a wrong implementation could still pass. Exact values, thrown errors and visible output survive refactors. Call counts on internal helpers and type checks don’t.
Even a clean suite has a limit. Passing unit tests show that the code handles the cases someone wrote down. They don’t show that a real user can complete the flow in a browser, and session replays of a shipped feature can still show the checkout breaking while CI is green.
When Isn’t TDD Worth It?
Test-first costs more than it returns when you can’t state the expected result up front.
| Situation | Why to skip test-first |
|---|---|
| Throwaway prototypes | The code is meant to answer a question, then be deleted |
| Visual UI tweaks (spacing, colour, layout) | You judge the right result by looking, so there’s nothing to assert yet |
If a prototype survives, write the tests before the agent hardens it into production code.
Conclusion
When agents write the code, the spec is the part only you can supply, and tests written first make it executable. For your next agent task, write the failing Vitest file first, give the agent the no-edit instruction, and merge only after CI and a separate review of the test diff both pass.
FAQs
Can a linter catch skipped tests that an agent adds?
Yes. The @vitest/eslint-plugin package has a vitest/no-disabled-tests rule that flags test.skip, it.skip and describe.skip, and a vitest/no-focused-tests rule that flags .only. The recommended config only warns on disabled tests, so set that rule to 'error' and run ESLint in CI. Jest projects get rules with the same names from eslint-plugin-jest. Lint can miss aliased calls, so keep the git diff guard as well.
Can an AI agent write the failing tests instead of a human?
Yes, as long as writing the tests and writing the implementation are separate steps. Have one agent session draft the tests against the stub, then review the assertions yourself and commit them before implementation starts. The implementing session should only ever see tests it is not allowed to change. If the same session writes both, the tests end up describing its own code rather than the requirements.
Is a detailed written spec enough without writing tests first?
No. A prose spec or doc-commented stub tells the agent what to build, but nothing checks the result against it. A failing test can't be satisfied by a plausible-sounding implementation: it either passes or it doesn't. Use both. Put the requirements and constraints in the prompt or instruction file, and encode the cases that must hold as tests that CI runs on every commit.
What should I do when the agent says a test is wrong?
Review the agent's explanation yourself, and if the test really is wrong, fix it outside the implementation branch. Land the corrected test on main in its own commit or pull request, reviewed under CODEOWNERS where you use it, then rebase the implementation branch. The diff guard compares the branch against origin/main, so a test fix that has already landed on main doesn't trip it, and the agent never edits the spec.