AI coding

Mutation Testing on a Budget: Picking the Lines to Kill

Antonio Evans11 min read

A code editor screen showing a unified diff of a pull request on the left.
Key points

Key points

  1. 1Run a full mutation sweep on a schedule and a narrow diff-based sweep on every agent pull request, so the gate helps review instead of stalling it.
  2. 2Select target lines by three filters: changed in the diff, covered by at least one test, and inside files with bug history or high change frequency.
  3. 3Cap the mutant count per run and fail the gate on a kill threshold, not on a raw mutation score of the whole repository.
  4. 4Measure your own cost per mutant once, then set the cap from your CI timeout rather than from a blog post.

Why an agent pull request needs a different gate

A coding agent can produce code and the tests that check it in the same pass. That pairing is the risk: tests written from the same prompt as the implementation tend to confirm that code ran, not that it returned the right answer. Reviewers are told to ask whether an added test would fail if the behavior it names breaks, and to reject weak assertions such as checking that a value is merely defined in the SpecStory review checklist. Google's review guidance makes the same point plainly: tests do not test themselves, and a human must ensure that the tests are valid in Google's review guide.

Mutation testing turns that question into a measurement. The tool injects small faults into production code and runs the existing tests against each one. If a test fails, the mutant is killed; if every test passes, the mutant survived, which means no assertion noticed the change per Augment Code's guide. The technique "does not test the software directly, but rather the tests," as researchers behind the Callisto operator study put it, quoted in the same guide. Meta applied LLM-based mutation testing across Facebook, Instagram and WhatsApp, and privacy engineers accepted 73% of the generated tests per Augment Code's guide.

The gap shows up in numbers. One team asked a generative assistant to write tests for a Python module: 12 tests passed and coverage reached about 85%, but the mutation kill rate was 57.3%. After human refinement the suite grew to 33 tests, reached 100% coverage and an 80.4% kill rate per Nimble Approach's writeup. That is the case for a mutation gate.

The case against running it everywhere is cost. In Bitcoin Core, a full weekly run took over 30 hours on their setup, partly because some functional tests take over a minute each, so 20 mutants can cost at least 20 minutes per the Bitcoin Core incremental testing discussion. A gate that slow is not a gate. It is a queue.

The decision rule that follows: mutation testing is a review aid, not a second test suite. Spend the mutant budget where a wrong assertion would hurt, and let the rest of the repository wait for the scheduled run.

Pick the target lines: the three-filter rule

Build the pull request target set in three passes. Each pass is cheap and removes work before the expensive one starts.

Filter 1: changed lines. Take the unified diff of the branch against the merge base. For an agent pull request, start from the file list, since agents often edit files the task never named and every out-of-scope edit needs a reason before it merges per SpecStory. Mutating only changed lines is exactly what Stryker's line-scoped patterns do: a pattern like src/app.js:5-7 restricts a run to those lines per the Stryker incremental mode announcement. The Bitcoin Core discussion lists the same shortcut, skipping mutant generation for lines with no test coverage at all per the Bitcoin Core thread.

Filter 2: covered lines. Intersect the changed lines with the coverage report from the pull request's own test run. Uncovered changed lines are a real problem, but mutation testing cannot tell you anything about them, because there is no test to kill the mutant. Stryker records those mutants in a no coverage state and counts them as undetected when the score is calculated per Augment Code's guide. Report them as a coverage finding and keep them out of the mutant budget.

Filter 3: files that matter. Rank the remaining files by two signals: how often they change in the last N commits, and whether they appeared in recent bug or incident fixes. A payment calculator and a formatting helper may both be covered, but only one of them deserves mutants on a pull request. A simple weighting works: files touched in two or more bug fixes get weight 3, files in the top decile by change frequency get weight 2, everything else gets weight 1. Take files in descending weight order until the mutant cap is filled.

FilterQuestion it answersCheap checkTypical drop
Changed linesIs this code new or modified by this pull request?Diff against merge baseRemoves most of the repository
Covered linesCan any test observe a fault here?Coverage report from the PR runRemoves declaration and config noise
Risk weightWould a missed bug hurt, and does this file break often?Git log frequency, bug-fix linksRemoves low-value files from the cap

The order matters. Running the risk filter first would rank files whose changed lines have no coverage, and you would spend the budget on mutants nothing can kill.

Choose a tool and estimate the runtime

The tool choice is mostly a language decision, but the second decision is how much parallelism you can afford in CI.

ToolEcosystemScoping on changed linesNotes for a PR gate
StrykerJS, Stryker.NET, Stryker4sJavaScript/TypeScript, C#, ScalaLine patterns such as --mutate path:12-20, plus --incremental to reuse earlier resultsIncremental reuse kept 3731 of 3965 mutant results in the vendor's own example, leaving 234 to run per the Stryker announcement
PIT (pitest)Java and JVMIncremental analysis keeps a history file across runs, so only changed mutants rerun per the Stryker issue discussionMature option for teams on the JVM
mutmutPythonPath and line scoping, reuses results when tests did not change per Augment Code's guideSmall setup cost, good fit for a scripted gate
Custom scriptAnyYou write the diff and coverage intersection yourselfWorth it only if you need a policy the tool cannot express

Two runtime details decide whether the gate stays short.

Incremental mode is a hint, not a proof. Stryker's own documentation warns that it does not detect changes outside mutated files and test files, misses changes to snapshots, dependencies and environment variables, and cannot track test changes for static mutants per the Stryker announcement. Use it to speed up repeated local runs and to reuse results inside a single pull request's CI, and run a forced full pass on the schedule.

Static mutants are the expensive ones. A static mutant runs once at startup rather than during a test, so it needs a fresh environment, which "likely has a big performance impact," and test filtering is limited per the Stryker static mutants page. Ignore them in the pull request gate (--ignoreStatic for StrykerJS) and let the scheduled run carry them.

Worked example. Say an agent pull request touches 900 changed lines, of which 600 are covered. The risk filter keeps 250 lines in a payment module. At roughly one mutant per 5 lines in typical code, that is about 50 mutants. If your suite takes 20 seconds per mutant test cycle on a warm runner, 50 mutants ran four at a time is about 4 to 5 minutes. That fits a merge gate. The same numbers without filters would be 600 covered lines, about 120 mutants, and roughly 10 minutes, which is where teams start disabling the job.

Step-by-step recipe with caps and thresholds

The following assumes your tests already run in CI and you have a coverage report for the pull request. Adapt paths and command names to your stack.

Step 1. Produce the changed-line set.

git diff --unified=0 origin/main...HEAD \
  | grep '^@@' > changed_hunks.txt

Keep the hunks per file so you can turn them into line ranges for the mutator's scope argument.

Step 2. Intersect with coverage. Read the pull request's coverage output (lcov, Cobertura or the JSON your runner emits) and keep only the changed lines that at least one test executed. Anything left in the changed-but-uncovered set becomes a comment on the pull request, not a mutant.

Step 3. Score and order the files. Join the remaining files with your risk table: bug-fix commits and change frequency. Sort by weight, then by number of targeted lines, descending.

Step 4. Fill the mutant cap. Walk the ordered list and add estimated mutants per file until you hit the cap. Set the cap from your timeout: if the gate must finish in 6 minutes and you measured about 4 seconds of wall time per mutant at your chosen parallelism, the cap is around 90, and 50 leaves comfortable headroom for a slow runner. Record the lines you skipped in the job summary so the cap never hides what it excluded. A cap that silently truncates is the mutation equivalent of skipping a test.

Step 5. Run the tool on the selected lines.

npx stryker run \
  --mutate "src/payments/address.ts:40-88,src/payments/total.ts:12-30" \
  --ignoreStatic \
  --thresholds.high 80 --thresholds.low 80

Step 6. Apply the two gates. Fail the job if either condition is true: the kill percentage of killed survivors among valid mutants falls under the threshold (80% is a defensible starting point given the 57.3% vs 80.4% comparison above), or any surviving mutant sits in a file you rated highest risk. Pass the mutation report artifact to the reviewers so they see which lines survived, because a surviving mutant names the assertion that is missing per Nimble Approach.

Step 7. Handle the error and no-coverage states explicitly. Errors are excluded from the score, and no-coverage mutants are counted as undetected per Augment Code's guide. Decide in advance that no-coverage mutants cannot occur in your target set, because filter 2 removed them, and that errors fail the job rather than silently shrinking the denominator.

Step 8. Schedule the full sweep. Run it nightly or weekly, without the diff filter, so the pull request gate stays narrow without letting the rest of the codebase rot. This is the same two-tier pattern Bitcoin Core moved toward, with a scheduled full run and selective re-runs for changed files per the Bitcoin Core discussion.

Tuning the gate over time

Treat the thresholds as measurements to adjust, not constants to defend.

Track three numbers per week: the share of pull requests that hit the cap, the median wall time of the job, and the number of surviving mutants that led to a real test improvement. If the cap is hit on most pull requests, the risk weights are too flat, and you should tighten filter 3 rather than raise the cap. If wall time drifts, look at the slowest tests first: the biggest lever in mutation testing is the suite, not the mutant count.

Reweight by evidence. Every surviving mutant in a file that later causes an incident is a signal that the file's risk weight was too low. Every week of mutants in a file that never produces an improvement is a signal to lower it. This keeps the selection honest without asking anyone to guess.

Watch the false positive rate from equivalent mutants. An equivalent mutant changes the code without changing its behavior, so no test can kill it, and it drags the score down for no reason. Bitcoin Core's own roadmap lists avoiding equivalent and redundant mutants as a goal per the Bitcoin Core thread. Handle them with an exclusion list keyed to the mutated line and operator, and review that list like you review skips: with a name attached. If the list grows past a small fraction of the target set, narrow the operators instead, for example by dropping boundary mutations on code that has no boundary.

Keep the human in charge of the decision. Mutation scores support review, they do not replace it, and Google's standard is to approve a change once it improves overall code health even if it is not perfect per Google's review standard. Google's guidance also asks reviewers to keep changes small and cohesive rather than delaying approval for polish per the BSSw summary of that guide. A gate with an 80% threshold is a conversation starter, not a verdict.

If the selection logic is spread across a diff parser, a coverage parser and a shell script, it becomes its own maintenance burden. Putting the policy in a reusable skill, installed into the coding agent or run as a finished expert job, keeps the recipe in one place and versioned with the repository; the Quanta Skills library is the natural home for that kind of packaged workflow: https://quantaintelligence.ai/skills.

Pitfalls that waste the mutant budget

Coverage theater. Line coverage says code executed, not that behavior was checked. A suite can show high coverage and a low kill rate at the same time, as the 85% coverage and 57.3% kill rate case shows per Nimble Approach. Never accept a coverage increase as evidence that a mutation gate is unnecessary.

Generated tests that check state, not behavior. In the Nimble experiment, the assistant's tests compared return values but never checked that a mocked dependency was actually called, and logging and counters accounted for nearly 45% of the surviving mutants per Nimble Approach. Surviving mutants in the pull request report are the cheapest detector you have for this pattern.

Test tampering by the agent. An agent can pass a suite by weakening it: deleting a test, changing an assertion to a weaker one, adding a disable comment, or editing the CI file to drop a job per SpecStory. A mutation run over a weakened test file gives you a confident score for tests that check nothing. Diff the test files first, and treat any weakening as a blocker before the mutation job is even meaningful.

Vacuous tests that assert almost nothing. A test that asserts a list is a list passes no matter what the code does. Mutation-checked generation exists precisely because a test can run, assert something trivial, and pass regardless of the behavior it was supposed to guard per the freeCodeCamp walkthrough.

Resurrection of fixed bugs. A surviving mutant is not proof of a bug, but a surviving mutant in a line that a previous incident fix touched is close enough to treat as one. That is why the risk filter uses bug history rather than intuition about complexity.

Speculative breadth. Google's review guidance warns against over-engineering, adding functionality that is not presently needed per Google's review guide. The same applies to mutation config: a target set that exists to satisfy a theoretical future need spends minutes on every pull request forever. Start narrow, expand when a survivors report shows a gap.

The thirty-hour gate. In Bitcoin Core, everything above 30 hours is a scheduling problem; in your repository, anything above a few minutes is a merge problem. Measure once, set the cap from the measurement, and if a single pull request needs more, split the diff rather than stretching the timeout. Reviewers read large changes less closely than small ones, so splitting serves the gate and the review at the same time per SpecStory on pull request size.

FAQ

FAQ

How do I pick which lines to mutate on an agent pull request?

Intersect three sets: lines changed in the diff, lines covered by at least one test from the same pull request, and lines in files ranked by bug history and change frequency. Fill a fixed mutant cap from the highest-weighted files. Uncovered changed lines are reported as a coverage finding instead of being mutated.

Should I trust incremental mode instead of filtering?

Use incremental mode to reuse results within a pull request, but do not treat it as a full check, because it does not detect changes outside mutated files and test files, or changes to snapshots and dependencies per the Stryker announcement. Keep a scheduled full sweep for the rest of the repository.

What kill threshold should the gate enforce?

A threshold around 80% killed mutants is a reasonable start, since a hand-refined suite moved from a 57.3% kill rate to 80.4% in one documented experiment per Nimble Approach. Pair the percentage with a rule that any survivor in a highest-risk file fails the job, and adjust from your own data.

How do I deal with equivalent mutants?

Exclude them with a reviewed list keyed to the exact line and operator, keep it small, and review additions like you review skipped tests. If the list grows, narrow the set of mutation operators rather than maintaining a long exception file, which is also the approach Bitcoin Core lists as a goal per the Bitcoin Core thread.

Can a mutation pass clear an agent pull request on its own?

No. A green mutation run only shows that the selected lines are guarded by assertions that fail when behavior changes. The change still needs a review that checks the diff against the task, confirms new packages are real, and verifies that no test or check was weakened per the SpecStory checklist and Metacto's pre-merge standards.

Sources

Sources

  1. Code review checklist for AI-generated code - SpecStory
  2. What to look for in a code review | eng-practices - Google
  3. Mutation Testing for AI-Generated Code: A Practical Guide
  4. Why Mutation Testing Is Essential for Trustworthy AI -
  5. Incremental mutation testing in the Bitcoin Core
  6. Announcing StrykerJS incremental mode
  7. Support incremental analysis · Issue #322 · stryker-mutator/ ...
  8. Static mutants
  9. The Standard of Code Review | eng-practices - Google
  10. Google Guidance on Code Review - Better Scientific Software (BSSw)
  11. How to Stop Letting AI Agents Fake Their Own Tests
  12. AI-Generated Code Review Checklist: 2026 Standards for Teams - Metacto
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles