Unit test strategies and generation with AI agents
PLUnit 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.mdorCLAUDE.mdso 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.
-
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.
-
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. -
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. -
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.
-
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.
Where should the mocks go in a unit test?
Section titled “Where should the mocks go in a unit test?”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.
| Dependency | In a unit test | Why |
|---|---|---|
| Database repository, HTTP client, message queue, file system | Mock or use an in-memory fake | Slow or non-deterministic, and owned by someone else |
| Clock, random numbers, UUIDs | Control them (fake timers, seeded generator, injected ID function) | Otherwise tests pass or fail by time of day |
| Your own validation, mapping and calculation helpers | Real | This is the behaviour under test |
| Cryptography and hashing libraries | Real, unless they are deliberately slow (bcrypt cost) | A mocked hash hides encoding bugs |
| Logging and audit calls | Real or spied, and assert what was recorded when it is a requirement | Audit 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, from the repository root (Stryker 10.0.0, checked 2026-09-26)npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runnerSave this as stryker.config.json:
{ "testRunner": "vitest", "reporters": ["clear-text", "html", "json"], "incremental": true, "thresholds": { "high": 80, "low": 60, "break": 60 }}# Mutate only the file under test; the json reporter writes reports/mutation/mutation.jsonnpx stryker run --mutate src/services/order.service.tsFor 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, from the repository rootclaude -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:
claude plugin marketplace add trailofbits/skillsclaude plugin install mutation-testing@trailofbitsTool-specific setup, including a reusable test-generation command, is on testing with Claude Code.
Behaviour list, read-only and headless:
# Terminal, from the repository root (Codex CLI 0.157.1)codex exec -c default_permissions=":read-only" -o order-behaviours.md "$(cat prompts/behaviour-list.md)":read-only is a built-in permission profile, OpenAI’s preferred control. Profiles are beta, there is no dedicated flag for them, and they do not compose with the legacy --sandbox flag, so do not pass both in one command.
Tests and negative controls: the second prompt writes files and runs Vitest, so it needs a writable workspace. --approve-for-me routes approval requests through automatic review in the workspace-write sandbox:
codex exec --approve-for-me "$(cat prompts/generate-tests.md)"Survivor triage skill:
codex plugin marketplace add trailofbits/skillscodex plugin add mutation-testing@trailofbitsMore on Codex surfaces for testing is on testing with Codex.
Behaviour list: paste the first prompt into the agent in Plan Mode, so it reports before it writes anything. Reference the file with @src/services/order.service.ts.
Tests and negative controls: run the second prompt in a new chat that contains the approved list, so the agent works from the list rather than from its earlier reading of the implementation. Before you accept, confirm that git status lists only test files.
Survivor triage skill: install it with the cross-agent skills CLI:
npx skills add trailofbits/skills --skill mutation-testing --agent cursorCursor-specific patterns are on testing patterns in Cursor.
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:
| Check | How it is produced | What it catches |
|---|---|---|
| Every approved behaviour has exactly one named test | Compare the list with it( names | Missing scenarios, invented ones |
| Every test has a negative control that went red | The table from the generation prompt | Tests that cannot fail |
| Mutation score on the changed file clears the team’s break threshold, survivors triaged | npx stryker run --mutate <file> | Weak assertions, missing boundaries |
Only test files changed, unless a line was marked BUG | git diff --name-only | Production code edited to make tests pass |
| No focused or skipped tests | Vitest 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 list | Tests switched off to go green |
| Tests pass in random order | npx vitest run --sequence.shuffle | Shared 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.
What breaks when agents write unit tests?
Section titled “What breaks when agents write unit tests?”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.