Getting AI to Write Tests That Actually Catch Bugs
Ask an AI for tests and you'll get plenty: tests that mock everything, assert nothing useful, and pass no matter what the code does. How to get tests that fail when behaviour breaks — what to test, how to prompt, how to check a test is real, and how tests become the agent's safety net.
AI makes writing tests cheap. That's the good news. The bad news is that AI-written tests are frequently theatre: they run, they pass, the coverage number goes up — and they'd keep passing if you deleted half the feature.
A test is only worth having if it fails when the behaviour it covers breaks. This guide is about getting tests that meet that bar, and using them as the feedback loop that keeps an agent honest.
How AI tests go wrong
Mocking the thing under test. The test replaces the database, the service, and the function's own helpers with mocks that return exactly what the assertion expects. The test verifies that mocks return what they were told to.
Asserting that code ran, not what it did. expect(sendEmail).toHaveBeenCalled() passes whether the email went to the right person with the right content or not.
Snapshotting everything. A snapshot of a component's output passes until anything changes, then gets regenerated without anyone reading the diff.
Testing implementation details. Tests coupled to internal function names and call order break on every refactor, so people learn to "fix" them by updating the expectations.
Mirroring the implementation. The test recomputes the expected value using the same logic as the code — so a bug in the logic is faithfully reproduced in the test.
Only happy paths. Valid input, logged-in user, everything present. The bugs live in the other paths.
What to test
Prioritise by what hurts if it breaks:
- Money — pricing, discounts, tax, what gets charged, what gets granted after payment.
- Permissions — user A can't read or change user B's data; non-admins can't reach admin actions. (Authentication vs Authorization.)
- Data integrity — creating, updating, and deleting leaves the database in a valid state.
- Core user journeys — the handful of flows your app exists for.
- Bugs you've fixed — every bug fix gets a test that would have caught it, so it never returns.
Pure logic (a function that calculates a price) is the easiest to test well. Test the permissions and data paths against a real database — a test database, reset between runs — rather than mocks. Integration tests that exercise the real query catch far more than unit tests with a mocked ORM.
Prompting for tests that bite
Vague requests get vague tests. Be specific about behaviour and constraints:
Write tests for
calculateOrderTotalinsrc/billing/total.ts. Cover: no discount; percentage discount; fixed discount larger than the subtotal (total must not go below zero); tax applied after discount; rounding to 2 decimal places. Use literal expected values computed by hand — don't reuse the implementation's logic. Don't mock anything in this module.
Write integration tests for the
/api/invoices/:idroute against the test database. Create two users with an invoice each. Assert: owner gets 200 and their invoice; the other user gets 404; an unauthenticated request gets 401. Don't mock the database or the auth check.
The key phrases: name the behaviours, specify the edge cases, require literal expected values, and say what not to mock.
Check that a test is real: break the code
The single most useful habit. After the AI writes a test, deliberately break the code it covers and confirm the test fails:
- Remove the permission check → does the "other user gets 404" test fail?
- Change
>=to>in the discount logic → does a test catch it? - Return the wrong field → does anything notice?
If the test still passes, it isn't testing that behaviour. You can ask the agent to do this itself:
For each test you wrote, temporarily introduce a bug in the code it covers, confirm the test fails, then revert. Report any test that still passed.
This is a manual version of mutation testing, which tools like Stryker automate for JavaScript — useful once a codebase is large enough to justify it.
Test-first with agents
A workflow that works particularly well with coding agents:
- Describe the behaviour and ask for tests only — no implementation.
- Read the tests. They're a precise, checkable statement of what you asked for. Correct them now; it's cheap.
- Run them and confirm they fail (the feature doesn't exist yet).
- Ask for the implementation that makes them pass, without modifying the tests.
Step 2 is where the value is: reviewing tests is much faster than reviewing implementation, and it catches misunderstandings before any code is written. Step 4's constraint stops the agent from "fixing" a failure by weakening the test.
Guard the tests themselves
Agents under pressure to make things pass will sometimes edit the test instead of the code. Defend against it:
- A rule in
CLAUDE.md: "Never change an existing test's expected values, skip a test, or delete a test to make it pass. If you believe a test is wrong, stop and explain why." - Review test diffs first in every PR. Any change to an existing assertion needs a stated reason. (How to Review a Pull Request Written by an AI Agent.)
- Watch for
.skip,.only,xit,expect(true), and loosened matchers (toBeTruthy()where a value was expected).
Make tests the agent's feedback loop
Tests matter more with agents than without, because they're how the agent knows it didn't break something it can't see:
- Keep them fast. A suite an agent can run in under a minute gets run constantly. A 20-minute suite gets skipped. Split fast unit/integration tests from slow end-to-end ones.
- Make them easy to run. One command, documented in
CLAUDE.md. - Enforce them. A
Stophook that runs the relevant tests before Claude Code can finish (Claude Code Hooks), and CI on every pull request (Set Up CI With GitHub Actions). - Keep failures readable. A subagent that runs tests and reports only failures keeps the main context clean. (Claude Code Subagents.)
End-to-end tests, sparingly
Browser-driven tests (Playwright is the common choice) exercise the full stack: click the button, see the result. They catch integration problems nothing else does, and they're slower and more brittle. Keep a small number covering the critical journeys — sign-up, the core action, checkout — and use role- and text-based selectors (getByRole('button', { name: 'Save' })) rather than CSS classes, so they survive redesigns.
The checklist
- Tests cover money, permissions, data integrity, core journeys, and fixed bugs
- Permissions and data tested against a real test database, not mocks
- Expected values are literals, not recomputed
- Every new test verified by breaking the code it covers
- Rule: agents never weaken, skip, or delete tests to pass
- Suite fast, one command, enforced by hook and CI
- A few end-to-end tests on critical journeys
EasySpawn gives Claude Code a real environment to test against — your app, a managed Postgres for a test database, and the full toolchain — so tests exercise real queries and real behaviour, not mocks. See how it works or join the waitlist.
Related: Why TypeScript Makes AI-Generated Code Safer · How to Test Your App Before Launch · Regular Expressions for Beginners
Keep reading
Why TypeScript Makes AI-Generated Code Safer
Types turn a whole class of AI mistakes — invented properties, wrong arguments, forgotten null checks — into errors caught before the code runs. How TypeScript acts as a feedback loop for agents, the settings that matter, and the escape hatches AI uses to switch it off.
How to Undo Almost Anything in Git (Including an AI Agent's Mess)
An agent committed to the wrong branch, rewrote files you needed, or ran a reset it shouldn't have. Git can almost always get your work back. Which undo command fits which situation — restore, revert, reset, and the reflog that rescues 'deleted' commits — explained with the exact commands.