Getting Claude Code to Write Tests That Catch Bugs

2026-07-11 · 6 min

Ask Claude Code to "write tests for this file" and you'll get a green suite in seconds. That's the problem. A green suite is not the same as a suite that fails when your code breaks — and the fast, obedient tests you get from a naive prompt are usually the kind that can't fail. This article is about the gap between those two things, and how to close it: what makes generated tests worthless, how to steer toward tests that actually pin behavior, which paths are worth the effort, and how to make the whole thing repeatable so you're not re-explaining it every session.

Everything here applies to any language and test runner. The examples are TypeScript because it's compact, not because the ideas are stack-specific.

Why "write tests" gives you tests that can't fail

When you hand a model a function and say "test this," the path of least resistance is to describe what the code does. That produces two failure modes, and you'll see both constantly.

Tautological tests re-derive the expected value the same way the code computes it. If the function multiplies by value / 100, the test computes subtotal * value / 100 and asserts they're equal. They always will be — including when the formula is wrong. The test mirrors the bug instead of catching it.

Over-mocked tests stub every collaborator, call the function, and then assert that the stubs were called. expect(db.save).toHaveBeenCalledWith(...) tells you the function called save. It tells you nothing about whether the thing it saved was correct, or whether saving was even the right thing to do. Rename an internal method and the test goes red; ship a real bug and it stays green. That's exactly backwards from what a test is for.

Both feel productive because coverage climbs and the bar turns green. But line coverage is a poor proxy for correctness — a module can be 100% covered by tests that assert nothing. The number going up doesn't tell you the tests would catch a regression.

Name the contract before writing a single assert

The fix isn't a magic prompt; it's forcing a specific order of operations. Before any expect, make Claude state the unit's contract — what it promises callers, independent of how it's built:

  • Inputs it accepts, and the ones it rejects.
  • Outputs and effects it guarantees for each class of input.
  • Invariants it must never violate ("never returns a negative total," "idempotent on retry").

The contract is what a caller would be angry about if it silently changed. That, and only that, is what you test. If Claude can't describe the contract without narrating the function body line by line, it doesn't understand the unit yet — and neither do its tests. Tell it to read the callers first.

A prompt that works in practice:

Before writing tests, state this function's contract in three bullets: what it accepts, what it guarantees, and its invariants. Then list the meaningful behaviors — each real branch, the boundaries, and every failure path. Only then write one test per behavior. Assert on return values and thrown errors, never on which internal methods got called. Do not compute the expected value the same way the code does — write the literal expected number.

That last sentence does the heavy lifting. "Write the literal expected number" is what kills tautology: a human (or a model reasoning from the spec, not the implementation) computes 90, and if the code produces 91, the test fails.

A concrete before / after

Here's the unit under test — a coupon applier with a real edge case baked in:

function applyCoupon(cart: Cart, coupon: Coupon): number {
  if (coupon.expiresAt < Date.now()) throw new CouponExpiredError();
  if (cart.subtotal < coupon.minSpend) throw new MinSpendNotMetError();

  const raw = coupon.type === "percent"
    ? cart.subtotal * (coupon.value / 100)
    : coupon.value;

  const discount = Math.min(raw, cart.subtotal); // never discount below zero
  return cart.subtotal - discount;
}

Before — the test a naive prompt produces:

test("applyCoupon applies the discount", () => {
  const cart = { subtotal: 100, minSpend: 0 } as Cart;
  const coupon = { type: "percent", value: 10, expiresAt: Infinity } as Coupon;

  const expected = cart.subtotal - cart.subtotal * (coupon.value / 100);
  expect(applyCoupon(cart, coupon)).toBe(expected);
});

One test, green, "covers" the function. It's also worthless. expected is the implementation copied into the test file, so the formula could be wrong and this still passes. And it never touches the two failure paths or the below-zero guard — the exact places a real bug lives.

After — behavior-focused, one claim per test, literal expected values, failure paths included:

test("percent coupon subtracts the right amount", () => {
  expect(applyCoupon({ subtotal: 100, minSpend: 0 }, percent(10))).toBe(90);
});

test("fixed coupon never discounts the total below zero", () => {
  expect(applyCoupon({ subtotal: 5, minSpend: 0 }, fixed(20))).toBe(0);
});

test("rejects an expired coupon", () => {
  expect(() => applyCoupon({ subtotal: 100, minSpend: 0 }, expired()))
    .toThrow(CouponExpiredError);
});

test("rejects a cart under the coupon's minimum spend", () => {
  expect(() => applyCoupon({ subtotal: 30, minSpend: 50 }, percent(10)))
    .toThrow(MinSpendNotMetError);
});

toBe(90) is a number computed from the spec, not copied from the code, so a broken formula fails the test instead of matching it. The below-zero case asserts 0, pinning the invariant that the guard exists for. Each failure path provokes its specific error, not a bare "it throws." And the test names read as sentences — when one goes red in CI, you know which promise broke without opening the file.

Cover the paths that matter, not the lines

Once the tests are behavioral, the next question is which units get this treatment. Not all of them equally. Chasing a coverage percentage spreads effort evenly across code where bugs are cheap and code where they're catastrophic. Rank by blast radius instead:

  • Auth and access control. Every branch that decides who can see or do what. The test that matters most is the negative one: the request that should be denied and gets a 403, not the happy path that's allowed.
  • Money. Pricing, discounts, refunds, currency, rounding, tax. Assert exact amounts, and always test the boundaries — zero, negative, the maximum, one past a threshold.
  • Data writes and state changes. Anything that persists, deletes, or mutates shared state. Test idempotency and the concurrent case where two requests race the same row.

For these, tell Claude explicitly: "This touches money — enumerate the boundary and failure cases, and assert exact values." Steering the scope of testing matters as much as steering the style.

Make it repeatable: a skill plus a review subagent

Re-typing the "state the contract first" prompt every session is a tax. Claude Code gives you two ways to make good testing the default, and they do different jobs.

A skill is a procedure that loads into your current conversation when the task matches — same thread, same context. It's the right tool for encoding your testing standard. A test-author skill whose body is the steps above (name the contract, enumerate behaviors, assert on observable outcomes, never on internals) means every "write tests" request follows the discipline automatically, without you restating it.

A subagent is a separate Claude with its own context window that you delegate a bounded task to. It's the right tool for a second opinion, because it isn't anchored to the reasoning that wrote the tests in the first place. A review subagent that reads your test file and flags tautologies, over-mocking, and assert-free tests catches the weak tests your main thread is blind to — the same reason a fresh reviewer catches bugs the author can't see.

The two compose: the skill sets the standard as you write, the subagent audits the result before you commit. If you want a working starting point rather than building both from scratch, the claude-code-starter repo ships a ship skill, an adversarial-reviewer subagent, and a bug-hunt workflow you can adapt — the same skill-plus-reviewer shape, applied to shipping code generally.

Conclusion

The failure mode isn't that Claude Code writes bad tests — it's that "write tests" is an underspecified request, and the fastest thing that satisfies it is a test that can't fail. Steer it: make it state the contract before asserting, write literal expected values instead of re-derived ones, exercise every failure path, and spend the most effort on auth, money, and data. Encode that as a skill so it's automatic, and put a review subagent between "written" and "committed." A test earns its place by failing when the behavior breaks and staying green when you merely refactor. Everything above is just how you get the model to write that kind, on purpose, every time.

Related guides

Set up Claude Code the right way

Start free with the open-source starter — a ship skill, an adversarial-reviewer subagent, a bug-hunt workflow, and a CLAUDE.md template. Want the full kit? The Pro Pack adds 6 skills, 3 subagents, 3 workflows, 4 templates and a handbook.

Free starter on GitHub Get the Pro Pack — $39