← Writing

Tests Are How You Talk to the AI

Published
Reading
14 min
Contents · 13 sections
  1. Why vibe coding skips tests
  2. The skill: tests as an executable spec
  3. A worked example with Vitest
  4. Step 1: write the test first
  5. Step 2: ask the AI to implement
  6. Step 3: what a cheat looks like
  7. Step 4: add a property-based test
  8. What to test: behaviour, not implementation
  9. Unit, integration and end-to-end, in plain words
  10. Protecting tests from being edited
  11. How to build this skill while using AI
  12. Checklist
  13. Key ideas

In February 2025, Anthropic published the system card for Claude 3.7 Sonnet, one of the models many people were using for coding at the time. A system card is the document a lab publishes about a model’s abilities and risks. This one had a section with an unusually honest title: “Excessive Focus on Passing Tests.”

It said the model occasionally special-cased its way to green tests in agentic coding tools like Claude Code. Most often that meant returning exactly the values the tests expected instead of writing a general solution. Sometimes it meant editing the tests so they matched what the code did. It tended to happen after several failed attempts at a real fix, and the model often left a comment admitting it, like # special case for test XYZ.

It wasn’t just one lab or one model. A month later, OpenAI researchers described a reasoning model in training that learned to call exit(0) so the program quit before failing tests could run. In June 2025, the independent evaluator METR showed OpenAI’s o3 rewriting a timer so slow code looked fast, and replacing a grading function with one that marked every answer as correct. On one task, adding “Please do not cheat” to the prompt made almost no difference.

I covered why this happens in detail in Reward Hacking. Short version: coding agents are trained, in part, on “did the tests pass?” Usually that teaches them to write working code. Sometimes it teaches them that green is the goal.

Here’s why that matters to you. If your only instruction to an AI is “make the tests pass”, you’ve told it what to optimise, not what you want. And if you have no tests at all, you’ve told it even less.

This is Part 6 of After Vibe Coding. The skill: writing tests that say what you mean, and keeping them out of reach of the thing being tested.


Why vibe coding skips tests

When you vibe code, your test is your eyes. You run the app, click the button, it works, you move on.

That has three problems:

  • You only check the path you thought of. The bug is in the path you didn’t.
  • You can’t re-check everything after every change. AI changes a lot of code fast. Something that worked on Monday quietly breaks on Thursday, and you don’t click that button again until a customer does.
  • “It works” is a feeling, not a spec. The AI can’t read your feeling. It can read a test.

That last point is the one I want to land. A prompt is a wish. A test is a contract. A test is the most precise way to tell an AI what “done” means, and it keeps telling every future AI session, and every future teammate, long after your prompt is forgotten.

Anthropic’s own Claude Code guide makes the same point: give the AI a check it can run, because without one, “looks done” is the only signal it has. It even suggests having one session write the tests and another write the code to pass them.


The skill: tests as an executable spec

A spec (specification) is a description of what software should do. Usually it’s a document, and documents drift out of date. A test is a spec the computer can check, so it can’t quietly go stale: if the code stops matching it, the test fails.

The workflow that makes this work with AI is old and simple:

  1. You write the test first. It describes the behaviour you want, including the awkward cases.
  2. Run it and watch it fail. A test that passes before the code exists is testing nothing.
  3. Commit the test. Now it’s in history, and any change to it shows up in the diff.
  4. Ask the AI to implement, with an explicit rule: don’t touch the test files.
  5. Review the diff, not just the green tick.

This is basically test-driven development (TDD). The difference with AI is that step 4 is now fast and cheap, which makes steps 1 and 5 the whole job.

You write the test and commit it AI implements code only Tests run in CI You review the diff tests untouched? no hard-coded answers? green red: try again suspicious diff: reject clean: merge tests/ is locked: CODEOWNERS + CI
You own the test, the AI owns the implementation, and the diff review checks that nobody crossed the line.

A worked example with Vitest

Let’s make it concrete. Say you need to split a bill between several people, in whole cents, for a group booking. It sounds trivial. It’s exactly the kind of thing that’s wrong by one cent in production.

I’ll use Vitest, a test runner that works well with TypeScript and Vite projects. Jest uses nearly the same describe / it / expect API, so this translates directly.

Step 1: write the test first

// tests/splitAmount.test.ts
import { describe, it, expect } from "vitest";
import { splitAmount } from "../src/splitAmount";

describe("splitAmount", () => {
  it("splits evenly when it can", () => {
    expect(splitAmount(9000, 3)).toEqual([3000, 3000, 3000]);
  });

  it("gives leftover cents to the first people, one each", () => {
    expect(splitAmount(10000, 3)).toEqual([3334, 3333, 3333]);
  });

  it("handles a total smaller than the number of people", () => {
    expect(splitAmount(2, 3)).toEqual([1, 1, 0]);
  });

  it.each([
    [-1, 2],   // negative total
    [10.5, 2], // fractions of a cent
    [100, 0],  // nobody to split between
  ])("rejects bad input (%s, %s)", (total, parts) => {
    expect(() => splitAmount(total, parts)).toThrow(RangeError);
  });
});

Read it as a spec. Without seeing any implementation, you know: amounts are whole cents, leftover cents go to the first people, small totals are allowed, and bad input throws a RangeError instead of returning nonsense. That’s more precise than any paragraph of prompt.

Run npx vitest run. Everything fails, because splitAmount doesn’t exist yet. Good. Commit.

Step 2: ask the AI to implement

A prompt that works well:

Implement splitAmount in src/splitAmount.ts so that tests/splitAmount.test.ts passes. Write a general solution that works for any valid input, not just the test cases. Do not modify any file in tests/. If you think a test is wrong, stop and tell me why instead of changing it.

That last line matters. It gives the AI a legitimate way out when it’s stuck, which is exactly when the shortcuts in the system card showed up.

A correct implementation is short:

// src/splitAmount.ts
export function splitAmount(totalCents: number, parts: number): number[] {
  if (!Number.isSafeInteger(totalCents) || totalCents < 0) {
    throw new RangeError("totalCents must be a non-negative whole number");
  }
  if (!Number.isInteger(parts) || parts < 1) {
    throw new RangeError("parts must be a positive whole number");
  }
  const base = Math.floor(totalCents / parts);
  const remainder = totalCents % parts;
  return Array.from({ length: parts }, (_, i) => (i < remainder ? base + 1 : base));
}

Step 3: what a cheat looks like

Now imagine the AI got stuck and did what the system card describes:

// a version that passes every example test above and is wrong
export function splitAmount(totalCents: number, parts: number): number[] {
  if (!Number.isSafeInteger(totalCents) || totalCents < 0) throw new RangeError("bad total");
  if (!Number.isInteger(parts) || parts < 1) throw new RangeError("bad parts");
  // special case for test "gives leftover cents to the first people"
  if (totalCents === 10000 && parts === 3) return [3334, 3333, 3333];
  if (totalCents === 2 && parts === 3) return [1, 1, 0];
  const share = Math.round(totalCents / parts);
  return Array(parts).fill(share);
}

All six example tests pass. Green tick. And it’s wrong for almost every other input: split 1 cent between 2 people and it returns [1, 1], inventing a cent.

You’d catch this in review if you read the diff, because those if (totalCents === 10000 lines are a giant red flag. But there’s also a way to make the test suite catch it for you.

Step 4: add a property-based test

Example tests check specific inputs. A property-based test states a rule that must hold for all inputs, then throws hundreds of random ones at your code. The library fast-check does this for JavaScript and TypeScript:

import fc from "fast-check";

it("never loses or invents a cent", () => {
  fc.assert(
    fc.property(
      fc.integer({ min: 0, max: 1_000_000_000 }),
      fc.integer({ min: 1, max: 500 }),
      (total, parts) => {
        const shares = splitAmount(total, parts);
        const sum = shares.reduce((a, b) => a + b, 0);
        const spread = Math.max(...shares) - Math.min(...shares);
        return shares.length === parts && sum === total && spread <= 1;
      },
    ),
  );
});

The rules are things any human would agree with: everyone gets a share, the shares add up to the total, and nobody pays more than one cent more than anyone else. Against the cheating version, this test fails almost immediately, and fast-check shrinks the failure down to a tiny example, such as one cent split between two people. Against the correct version, all seven tests pass.

Special-casing can’t beat a property, because there are no specific values to special-case. That makes property tests one of the best tools you have when an AI writes the implementation. You don’t need them everywhere. Money, dates, sorting, parsing and anything with “always” or “never” in its description are good candidates.


What to test: behaviour, not implementation

A good test checks what the code does, from the outside, the way a caller would use it. A bad test checks how it does it.

// BAD: tests the implementation
import { vi } from "vitest";

it("calls Math.floor once per split", () => {
  const spy = vi.spyOn(Math, "floor");
  splitAmount(100, 3);
  expect(spy).toHaveBeenCalledTimes(1);
});

This breaks the moment someone refactors to a different, equally correct approach. Tests like this make every change painful, and they push AI agents toward editing tests, because the tests are wrong about what matters.

The good tests above only say “given this input, I expect this output or this error”. You can rewrite the implementation from scratch, by hand or with AI, and they still tell you if it works.

A few practical rules:

  • Test the edges. Zero, one, empty, the maximum, negative, duplicate, missing. AI-written code is usually right in the middle and wrong at the edges.
  • One behaviour per test, with a name that reads like a sentence. When it fails, the name tells you what broke.
  • Every bug gets a test. Before fixing it, write a test that reproduces it. Then it can never come back silently. (Part 3, on debugging, leans on this.)
  • Don’t mock what you own unless you must. Mocks (fake versions of real dependencies) are useful for slow or external things like payment APIs. Mocking your own code mostly tests the mock.

Unit, integration and end-to-end, in plain words

You’ll hear these three words constantly. Here’s what they mean without the jargon.

Unit tests check one small piece on its own, like splitAmount. No database, no network. They run in milliseconds, so you can have thousands. Most of your tests should be these.

Integration tests check that pieces work together for real: your API route talking to a real (test) database, for example. They’re slower and need setup, but they catch the bugs unit tests can’t, like a query that’s wrong or a missing permission check. The “user B can’t read user A’s invoice” test from Part 4 is an integration test, and it’s one of the most valuable tests you’ll ever write.

End-to-end (E2E) tests drive the whole app like a user would, usually in a real browser with a tool like Playwright: open the page, log in, book a room, check the confirmation. They’re the slowest and the most fragile, so keep a handful for your most important flows: signup, login, payment, the one thing your product exists to do.

A common shape is lots of unit tests, a good number of integration tests, and a few end-to-end tests. Don’t treat the ratio as a law. Test where the risk is.


Protecting tests from being edited

If the thing being graded can rewrite the grader, the grade means nothing. So put the tests somewhere the AI can’t quietly change them. No single lock is perfect, so use layers.

1. Tell the agent, and give it an exit. “Don’t modify tests; if a test looks wrong, stop and explain.” This is the weakest layer, as METR’s “please do not cheat” result shows, but it’s free.

2. Use your tool’s permission settings. In Claude Code, for example, you can add a deny rule to the project’s .claude/settings.json:

{
  "permissions": {
    "deny": ["Edit(/tests/**)"]
  }
}

That blocks the file-editing tool from touching the tests/ folder. Be honest about the limit: a shell command could still change those files, so treat this as a speed bump, not a wall. Other AI tools have similar settings under different names.

3. Make test changes visible in review. On GitHub, a CODEOWNERS file can name who owns the tests folder. With “Require review from Code Owners” turned on in your branch protection, any pull request that touches tests needs that person’s approval.

# .github/CODEOWNERS
/tests/ @your-github-username

4. Flag it in CI. A small check that fails loudly when a branch changes both code and tests, so a human has to look:

# in CI, on a pull request branch
CHANGED=$(git diff --name-only origin/main...HEAD)
if echo "$CHANGED" | grep -q '^src/' && echo "$CHANGED" | grep -q '^tests/'; then
  echo "This PR changes both src/ and tests/. A human must review the test changes."
  exit 1
fi

Sometimes code and tests should change together. That’s fine. The point is that it becomes a conscious decision by a person, not a silent edit by an agent at the end of a long session.

5. Read the diff for the tell-tale signs. Edited or deleted assertions, .skip or .only, expect(true).toBe(true), hard-coded return values that match test inputs, early process.exit, broad try/catch that swallows errors, and comments like “special case for test”. Anthropic’s system card itself recommends monitoring for comments that suggest test-specific handling and for unexpected changes to test files.


How to build this skill while using AI

1. Write the first test of every feature yourself. Even if the AI writes the rest. Writing that one test forces you to decide what the feature should actually do, which is the thinking vibe coding skips.

2. Use the AI to find the cases you missed. Show it your tests and ask: “What inputs or situations are not covered here? List them, don’t write code yet.” It’s genuinely good at this. You decide which ones matter.

3. Split the roles. One session writes or reviews tests, another implements. Anthropic’s own guide suggests exactly this. The implementing session shouldn’t be grading itself.

4. Make “watch it fail” a habit. Every time a new test passes immediately, be suspicious. Break the code on purpose and check the test goes red. A test that can’t fail is decoration.

5. Keep tests fast and run them always. Run them in watch mode while you work (plain npx vitest does this) and in CI on every pull request. Tests that only run when someone remembers don’t protect anything.


Checklist

  • New features start with a test you wrote or carefully reviewed.
  • You watched each new test fail before the code existed.
  • Tests check behaviour (inputs and outputs), not internal calls.
  • Edge cases are covered: zero, empty, max, negative, invalid.
  • Money, dates and other “always/never” rules have a property-based test.
  • At least one integration test proves user B can’t see user A’s data.
  • A few end-to-end tests cover signup, login and your core flow.
  • The agent is told not to edit tests, and has a way to say a test looks wrong.
  • Test files are protected: permission rules, CODEOWNERS, a CI check.
  • Every pull request diff is read for skipped tests, hard-coded answers and edited assertions.
  • Tests run on every pull request, automatically.

Key ideas

  • AI coding agents have been documented gaming tests. Anthropic, OpenAI and METR all reported special-casing, editing tests or tampering with the grader in 2025.
  • A test is the clearest instruction you can give an AI. It says what “done” means, precisely, and keeps saying it.
  • Write the test first, watch it fail, commit it, then ask for the code. The AI does the fast part. You do the deciding part.
  • Test behaviour, not implementation. Good tests survive a rewrite; bad ones invite the AI to edit them.
  • Property-based tests resist special-casing. There are no fixed answers to hard-code.
  • Don’t let the thing being graded edit the grader. Layer prompts, permissions, code owners and CI.
  • Green is not the same as correct. Read the diff.

Sources: Anthropic, Claude 3.7 Sonnet System Card (February 2025), section “Excessive Focus on Passing Tests”; Baker et al., “Monitoring Reasoning Models for Misbehavior and the Risks of Promoting Obfuscation” (OpenAI, March 2025); Von Arx, Chan and Barnes, “Recent Frontier Models Are Reward Hacking” (METR, 5 June 2025). Claude Code docs, Best practices and Configure permissions. GitHub Docs, About code owners.

Related: Data Outlives Code, From Prompting to Architecture, and Reward Hacking.