# Tests Are How You Talk to the AI

> AI coding agents have been caught editing tests to make them pass. Write the test first, protect it, and it becomes the clearest spec you can give.

- Author: Tai Pham Phu
- Published: 22 Oct 2026
- Tags: Vibe Coding, Software Design, Career, Testing
- URL: https://phamphutai.com/2026/10/22/tests-are-how-you-talk-to-the-ai/

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](https://phamphutai.com/2026/10/20/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.

<figure class="diagram">
<svg viewBox="0 0 680 270" role="img" aria-label="Workflow loop. You write the test, which is the spec, and commit it. The AI implements the code. Tests run: if red, the AI tries again. If green, you review the diff: were test files changed, are there hard-coded values or skipped tests? If the diff is clean, merge. If not, reject and go back to the AI. The test files sit behind a lock the AI cannot open." xmlns="http://www.w3.org/2000/svg">
<defs><marker id="tt-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="currentColor"/></marker></defs>
<rect x="1" y="40" width="164" height="70" fill="currentColor"/>
<text x="83" y="70" font-size="13" text-anchor="middle" font-weight="bold" class="on-fg">You write the test</text>
<text x="83" y="90" font-size="12" text-anchor="middle" class="on-fg">and commit it</text>
<rect x="193" y="40" width="126" height="70" fill="none" stroke="currentColor"/>
<text x="256" y="70" font-size="13" text-anchor="middle" font-weight="bold">AI implements</text>
<text x="256" y="90" font-size="12" text-anchor="middle">code only</text>
<rect x="349" y="40" width="108" height="70" fill="none" stroke="currentColor"/>
<text x="403" y="70" font-size="13" text-anchor="middle" font-weight="bold">Tests run</text>
<text x="403" y="90" font-size="12" text-anchor="middle">in CI</text>
<rect x="503" y="40" width="176" height="70" fill="none" stroke="currentColor" stroke-width="2"/>
<text x="591" y="64" font-size="13" text-anchor="middle" font-weight="bold">You review the diff</text>
<text x="591" y="83" font-size="12" text-anchor="middle">tests untouched?</text>
<text x="591" y="100" font-size="12" text-anchor="middle">no hard-coded answers?</text>
<line x1="165" y1="75" x2="191" y2="75" stroke="currentColor" stroke-width="1.5" marker-end="url(#tt-arrow)"/>
<line x1="319" y1="75" x2="347" y2="75" stroke="currentColor" stroke-width="1.5" marker-end="url(#tt-arrow)"/>
<line x1="457" y1="75" x2="501" y2="75" stroke="currentColor" stroke-width="1.5" marker-end="url(#tt-arrow)"/>
<text x="479" y="66" font-size="11" text-anchor="middle" class="muted">green</text>
<path d="M403 110 L403 140 L266 140 L266 112" fill="none" stroke="currentColor" stroke-width="1.5" marker-end="url(#tt-arrow)"/>
<text x="328" y="156" font-size="11" text-anchor="middle" class="muted">red: try again</text>
<path d="M540 110 L540 180 L246 180 L246 112" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#tt-arrow)"/>
<text x="385" y="198" font-size="11" text-anchor="middle" class="muted">suspicious diff: reject</text>
<text x="630" y="140" font-size="12" text-anchor="middle">clean: merge</text>
<rect x="13" y="216" width="140" height="44" fill="none" stroke="currentColor" stroke-dasharray="3 3"/>
<text x="83" y="236" font-size="12" text-anchor="middle">tests/ is locked:</text>
<text x="83" y="252" font-size="12" text-anchor="middle">CODEOWNERS + CI</text>
<line x1="83" y1="216" x2="83" y2="112" stroke="currentColor" stroke-dasharray="3 3"/>
</svg>
<figcaption>You own the test, the AI owns the implementation, and the diff review checks that nobody crossed the line.</figcaption>
</figure>

---

## 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

```ts
// 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:

```ts
// 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:

```ts
// 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:

```ts
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.

```ts
// 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](https://phamphutai.com/2026/10/22/debugging-when-the-ai-is-out-of-ideas/), 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](https://phamphutai.com/2026/10/22/security-holes-ai-writes-every-day/) 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`:

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

```text
# .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:

```bash
# 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](https://www.anthropic.com/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"](https://arxiv.org/abs/2503.11926) (OpenAI, March 2025); Von Arx, Chan and Barnes, ["Recent Frontier Models Are Reward Hacking"](https://metr.org/blog/2025-06-05-recent-reward-hacking/) (METR, 5 June 2025). [Claude Code docs, Best practices](https://code.claude.com/docs/en/best-practices) and [Configure permissions](https://code.claude.com/docs/en/permissions). [GitHub Docs, About code owners](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners).*

*Related: [Data Outlives Code](https://phamphutai.com/2026/10/22/data-outlives-code/), [From Prompting to Architecture](https://phamphutai.com/2026/10/22/from-prompting-to-architecture/), and [Reward Hacking](https://phamphutai.com/2026/10/20/reward-hacking/).*
