Skip to content

Unit tests generated by an AI agent catch real bugs when a human approves the list of behaviours before any test exists, mocks stay at service boundaries such as the database and external APIs, and every test is proven able to fail. A mutation tool such as Stryker then shows which deliberate bugs the new tests still miss.

This page is for developers who ask an agent for unit tests, and for tech leads who decide what “tested” means on the team. You ask for “unit tests for UserService” and get 40 green tests at 95% line coverage. A week later, users with a plus sign in their email address cannot register. The 40 tests covered one happy path in 40 variations, and nobody read them closely enough to notice.

What you’ll walk away with from agent-generated unit tests

Section titled “What you’ll walk away with from agent-generated unit tests”
  • A five-step workflow that turns a module into a behaviour list, tests, negative controls and a mutation run.
  • Five copy-paste prompts: behaviour list, test generation, boundary mocking, surviving mutants, and refactor-safe test updates.
  • A mocking rule you can put in AGENTS.md or CLAUDE.md so the agent stops mocking the code under test.
  • The checks that let you accept a test file without reading every assertion, and who signs off on them.
  • Recovery steps for the six ways agent-written unit tests go wrong.

Why do agent-written unit tests pass without catching bugs?

Section titled “Why do agent-written unit tests pass without catching bugs?”

An agent that writes tests for existing code reads the implementation first. It then writes tests that describe what the code does, including its bugs, and picks inputs the code already handles. Four patterns follow from that:

  • Happy-path variations. Many tests exercise the same branch with different data, so coverage rises and nothing new is checked.
  • Mocking the unit itself. The agent mocks internal helpers, so the test verifies the mocks.
  • Assertions on calls, not results. expect(repo.save).toHaveBeenCalled() passes whatever value was saved.
  • Editing to green. When a test fails, the agent changes the assertion or the production code until it passes.

Each pattern survives a green run and a coverage report. The workflow below closes each one with a check you can run, not a line you have to read. The measures behind that (mutation score, behaviour coverage, provenance, and flake rate) are defined on how strong is your oracle.

Generate unit tests from an approved behaviour list

Section titled “Generate unit tests from an approved behaviour list”

The order matters: you approve what is tested before the agent writes how. For new code, do this before the implementation, as described in test-driven development with AI agents. For legacy code whose behaviour nobody can state, pin what it does today with characterization tests first.

  1. Get a behaviour list, not tests. Ask the agent for a numbered list of behaviours for one public method, grouped as happy path, boundaries, invalid input, and dependency failures. Each line names the input and the expected result. Where a story exists, derive the list from its executable acceptance criteria.

  2. Approve and correct the list. This is the one artifact you read line by line. It is short, and it is where the agent’s guesses about intended behaviour surface. Mark any line where the code’s current behaviour is wrong as BUG, so the agent writes a failing test for it instead of pinning the bug.

  3. Generate one test per approved line. The test name repeats the line, the test goes through the public API, and mocks sit only at boundaries (see the next section). The agent runs the file and fixes failing tests, never the production code, unless a line is marked BUG.

  4. Prove each test can fail. For every test, the agent breaks the behaviour it checks (flips a comparison, returns early), runs the test, confirms it goes red, and reverts. This is the negative control. A test that stays green against a broken implementation is deleted or rewritten.

  5. Mutation-test the file and kill the survivors. Run Stryker on the one production file, feed the surviving mutants back, and repeat until every survivor is killed or triaged as equivalent. Commit the tests with the behaviour list in the pull request description.

Commit or stash order.service.ts before you run this prompt. If the agent ignores the instruction and reverts with git checkout --, an uncommitted implementation is lost, not only the one-line break. In test-first work the implementation is often still uncommitted.

Mock where your code talks to something it does not own, and run the real code everywhere else. Over-mocking makes a test prove nothing; mocking nothing makes a unit test slow and flaky. Put the rule in your instruction file so every session starts with it.

DependencyIn a unit testWhy
Database repository, HTTP client, message queue, file systemMock or use an in-memory fakeSlow or non-deterministic, and owned by someone else
Clock, random numbers, UUIDsControl them (fake timers, seeded generator, injected ID function)Otherwise tests pass or fail by time of day
Your own validation, mapping and calculation helpersRealThis is the behaviour under test
Cryptography and hashing librariesReal, unless they are deliberately slow (bcrypt cost)A mocked hash hides encoding bugs
Logging and audit callsReal or spied, and assert what was recorded when it is a requirementAudit logs are behaviour; debug logs are not

Add to AGENTS.md / CLAUDE.md:

## Unit tests
- Mock only boundaries: database, HTTP, queues, file system, clock, random/UUID.
- Never mock code inside the module under test; test it through its public API.
- Assert on returned values and recorded side effects, not only on mock calls.
- Never write a bare `toThrow()`; assert the error type.
- Use fake timers and seeded data; no real time, no unseeded randomness.
- Never edit production code to make a test pass; report the failure instead.

Test data belongs in factories with realistic defaults and explicit overrides. The factory pattern, seeds and PII-free synthetic data are on test data strategies and fixtures.

How do you get error paths and edge cases tested?

Section titled “How do you get error paths and edge cases tested?”

Name them. An agent left to choose picks inputs the code already handles, so the prompt has to list the input classes. For each public method, ask for tests at the boundaries of every numeric range, for empty and single-element collections, for Unicode and whitespace in strings, for rounding, and for each way each dependency can fail. For a rule that holds across all inputs (“the total is never negative”), a handful of examples is weak evidence; write it as a property instead, as described in property-based testing for agent-written code.

Error tests have one extra requirement: assert the error type and the side effects, not merely that something was thrown. A test that expects toThrow() with no argument passes when the code throws a TypeError from a typo.

Make time, randomness and async code deterministic

Section titled “Make time, randomness and async code deterministic”

Agent-written unit tests often use the real clock, unseeded random data, or setTimeout waits, and then fail intermittently in CI. Require vi.useFakeTimers() and vi.setSystemTime() for anything date-dependent, a fixed seed for any data generator, and awaited promises instead of sleeps.

Be honest about concurrency. A unit test that fires two calls with Promise.all interleaves them only at await points inside one process. It can catch a check-then-act bug in your own async code, but it cannot prove that two workers or two database transactions do not race. That proof needs the real database, which is integration testing.

Kill surviving mutants on the file you just tested

Section titled “Kill surviving mutants on the file you just tested”

Mutation testing changes the production code in small ways (a > becomes >=, a call is removed) and reruns the tests. A mutant that no test notices survived, and each survivor is a bug your tests would miss. Run it on the one file you just tested, so the loop takes minutes, not hours.

Terminal window
# Terminal, from the repository root (Stryker 10.0.0, checked 2026-09-26)
npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner

Save this as stryker.config.json:

{
"testRunner": "vitest",
"reporters": ["clear-text", "html", "json"],
"incremental": true,
"thresholds": { "high": 80, "low": 60, "break": 60 }
}
Terminal window
# Mutate only the file under test; the json reporter writes reports/mutation/mutation.json
npx stryker run --mutate src/services/order.service.ts

For Jest, install @stryker-mutator/jest-runner and set "testRunner": "jest". thresholds.break makes Stryker exit with code 1 below that score; the 60 here is a starting value, not a benchmark. Choosing the team’s threshold and wiring the per-pull-request gate in CI are covered on how strong is your oracle.

Review the EQUIVALENT list yourself. It is short, and it is where an agent can hide a real gap behind a confident sentence.

How do you run this in Claude Code, Codex and Cursor?

Section titled “How do you run this in Claude Code, Codex and Cursor?”

The workflow, prompts and Stryker commands are the same in all three tools. What differs is how you keep the behaviour-list step read-only and how you install a skill that triages survivors. Commands were checked against Claude Code 2.1.283, Codex CLI 0.157.1 and the Trail of Bits marketplace on 2026-09-26.

Trail of Bits publishes a mutation-testing plugin (1.9.2). Its skill reads the source and tests behind each survivor, separates equivalent mutants from testing gaps, and writes recommendations to mutation-testing-report.md without implementing them. It sets up campaigns only for mewt and muton, but its README says the analysis step “also accepts results from other mutation testing tools”, so you can hand it Stryker’s mutation.json.

Save the first two prompts as prompts/behaviour-list.md and prompts/generate-tests.md; the commands below read them from there.

Behaviour list, read-only and headless:

Terminal window
# Terminal, from the repository root
claude -p "$(cat prompts/behaviour-list.md)" \
--tools "Read,Grep,Glob" \
--max-budget-usd 2 > order-behaviours.md

--tools limits the session to the listed built-in tools, so Edit, Write and Bash are not available at all. --allowedTools alone only pre-approves tools and leaves the others in place, so a permissive project setting or permission mode could still let the run edit files (checked against Claude Code 2.1.283).

Tests and negative controls: run the second prompt in an interactive claude session so you see each break-and-revert. Keep the approved behaviour list in the session, not only in a file the agent can edit.

Survivor triage skill:

Terminal window
claude plugin marketplace add trailofbits/skills
claude plugin install mutation-testing@trailofbits

Tool-specific setup, including a reusable test-generation command, is on testing with Claude Code.

Update tests after a refactor without changing what they prove

Section titled “Update tests after a refactor without changing what they prove”

When an interface changes, the agent should change how results are asserted and leave what is asserted alone. Say so, and ask for evidence that the scenarios are unchanged.

How do you know the unit tests are good without reading them?

Section titled “How do you know the unit tests are good without reading them?”

Read the behaviour list; let checks cover the tests. A test file is ready to merge when all of these hold:

CheckHow it is producedWhat it catches
Every approved behaviour has exactly one named testCompare the list with it( namesMissing scenarios, invented ones
Every test has a negative control that went redThe table from the generation promptTests that cannot fail
Mutation score on the changed file clears the team’s break threshold, survivors triagednpx stryker run --mutate <file>Weak assertions, missing boundaries
Only test files changed, unless a line was marked BUGgit diff --name-onlyProduction code edited to make tests pass
No focused or skipped testsVitest fails on .only in CI by default (--allowOnly defaults to !process.env.CI; Vitest 5.0.3, checked 2026-09-26); grep the diff for .skip, it.todo and it.fails; every it.fails must match a line marked BUG in the approved listTests switched off to go green
Tests pass in random ordernpx vitest run --sequence.shuffleShared mutable state between tests

The developer who approved the behaviour list signs off on the pull request, and the tests’ evidence goes into the evidence bundle. The tech lead owns the break threshold and the rule for who may change an approved test. Once tests gate an agent’s work, lock them so the implementing agent cannot edit them, as described in protecting the oracle.

The tests all look the same. The prompt named a method but no behaviours. Recover by deleting the file, running the behaviour-list prompt, and generating from the approved list only.

The tests assert only that mocks were called. The negative-control table shows green results against broken code. Ask the agent to replace each call-only assertion with an assertion on the returned value or the persisted argument (toHaveBeenCalledWith with the exact object), then rerun the negative controls.

The agent fixed the code or weakened an assertion to get green. git diff --name-only shows a production file, or an expected value changed. Revert the production change, restore the original assertion, and treat the failing test as a finding: either the behaviour list was wrong or the code has a bug. Decide which, and mark it BUG if it is the code.

The tests pin an existing bug. Tests written from the implementation encode what it does. The behaviour-list review is the defence; if a bug is found later, fix the expected value in the test first, watch it fail, then fix the code. When the code is fixed, remove the it.fails marker, or the now-passing test turns red.

The tests pass locally and fail in CI. Run with --sequence.shuffle to expose order dependence, reset mocks before each test (vi.resetAllMocks() in beforeEach, or mockReset: true in the Vitest config), and replace any real clock or unseeded random data with controlled values.

The mutation loop never converges. The remaining survivors are equivalent mutants or live in logging and defensive code. Stop when every survivor is triaged, record the equivalent ones in the pull request, and exclude the file region from mutation only with a comment that says why.