AI

AI Test Coverage: Which Tests Are Still Worth Writing

Table of Contents

This post is for engineers and tech leads whose coverage number went up the month a coding agent started writing tests. An agent can take AI test coverage from 60 percent to 100 percent in an afternoon, which raises a fair question: did the suite get better at catching bugs, or only at running lines? Instead of answering from opinion, this post measures it. It takes one billing module and writes six test suites against it, in the six shapes that agents and busy humans tend to produce. Each suite is then scored three ways: line coverage, the share of 108 automatically generated bugs it catches, and how often it breaks on changes that should not break anything. The result is a ranking of which tests are still worth writing, and which ones an agent should be told to stop producing.

The numbers below came from this environment:

Measured 2026-11-04
Windows 11 Pro 25H2 (build 26200), AMD Ryzen 5 8600G (6C/12T), 16 GB RAM
Dart SDK 3.9.0 (stable), package:test 1.31.0, package:coverage 1.16.0, package:mocktail 1.0.5
One library (195 lines of code), six suites, 114 mutants, four safe changes

Dart was chosen because the test runner, the coverage collector and the mocking library all install with one dart pub get, so every number here comes from one toolchain. However, nothing in the results depends on Dart. The six suite shapes exist in every language, and a later section maps the mutation tools across stacks.

Why AI Test Coverage Stopped Being a Useful Signal

Line coverage measures which lines ran during the tests, not whether any test would fail if those lines were wrong. Before agents, reaching high coverage took effort, so the number loosely tracked care. Now an agent reaches 100 percent in minutes, which means AI test coverage mostly measures how much test code was generated.

That gap was known long before agents. Google’s code coverage best practices already warned that coverage does not guarantee the covered lines were tested correctly, and against treating a high percentage as the goal in itself. Similarly, the State of Mutation Testing at Google paper describes mutation testing as the better signal and shows how to run it on changed code at scale. What changed is the cost. When every test took an engineer real time to write, few people wrote tests that only called functions and checked the result type. An agent writes them in seconds, and a coverage gate rewards every one.

The cost of reading and maintaining those tests, on the other hand, did not drop at all. Every generated test is code that has to be updated when the implementation moves. Consequently, the useful question is no longer “how much coverage do we have” but “which tests earn their maintenance”, and that question has a measurable answer.

The Bench: One Billing Module, Six Test Suites

The library is a small slice of a subscription billing system in three files. pricing.dart holds pure rules: proration for a mid-period plan change, the next billing date with month-end clamping, percent and fixed discounts, tax in basis points, and coupon code normalization. Next, invoice.dart builds an invoice where the discount comes off before tax. Finally, renewal.dart holds a RenewalService that loads an account and its plans, charges a payment gateway, retries timeouts up to three attempts, and saves the invoice as paid, free or failed.

Every function carries a short spec comment, and the spec is what the tests are supposed to check. Here is one of the rules, because it comes back later:

/// The billing date [months] after [anchor]. Keeps the anchor's day of month,
/// clamped to the last day of shorter months: Jan 31 plus one month is Feb 28
/// (Feb 29 in leap years), and plus two months is Mar 31 again.
DateTime nextBillingDate(DateTime anchor, int months) {
  if (months < 0) throw ArgumentError.value(months, 'months');
  final monthIndex = anchor.month - 1 + months;
  final year = anchor.year + monthIndex ~/ 12;
  final month = monthIndex % 12 + 1;
  final lastDay = DateTime.utc(year, month + 1, 0).day;
  final day = anchor.day < lastDay ? anchor.day : lastDay;
  return DateTime.utc(year, month, day);
}

The public entry point, lib/billing.dart, hides the two internal helpers, divideHalfUp and daysBetween. That detail matters, because two of the six suites import them from src/ anyway, which is a common result of asking an agent to test a file rather than a feature.

Six Test Shapes and the Instruction Each One Models

Each suite was written for this bench to represent one shape, and every suite except the interaction one covers the whole library in that shape. None of them is a sample of any particular model’s output, so the bench says nothing about how often a given agent produces each shape. All six pass against the clean code.

SuiteShapeInstruction it modelsTestsTest lines
mirrorExpected values read off the implementation, helpers tested directly, error messages asserted word for word“Write unit tests for this file”14102
coverageEvery branch runs, assertions check type and range only“Raise line coverage to 100%”13130
specOne test per sentence of each spec comment, public API only, in-memory fakes“Test this against the spec”32185
property500 seeded random cases per rule, checked against invariants or a reference“Find inputs that break this”7135
interactionMocks that verify calls, arguments, counts and order“Test the service in isolation”571
snapshotWhole outputs serialized and compared with recorded golden files“Lock in the current behavior”3112 + 152 golden

Test lines count non-blank, non-comment lines. In addition, the spec, property and snapshot suites share a 45-line file of in-memory fakes. The interaction suite only tests RenewalService, because mocking has nothing to replace in pure functions, so its whole-library numbers are unfair to it and a per-file breakdown follows below.

What the Shapes Look Like Side by Side

The difference between the coverage-chaser and the spec suite is easiest to see on the same function. Both of these run every line of discountCents:

// coverage_test.dart: every branch runs, nothing checks the arithmetic.
test('discountCents covers all branches', () {
  expect(discountCents(1000, null), 0);
  expect(discountCents(1000, const PercentOff(10)), isA<int>());
  expect(() => discountCents(1000, const PercentOff(101)), throwsArgumentError);
  expect(discountCents(1000, const AmountOff(100)), lessThanOrEqualTo(1000));
  expect(discountCents(1000, const AmountOff(5000)), lessThanOrEqualTo(1000));
  expect(() => discountCents(1000, const AmountOff(-1)), throwsArgumentError);
});

// spec_test.dart: one test per spec sentence, boundaries named explicitly.
test('percent off rounds half up', () {
  expect(discountCents(1250, const PercentOff(10)), 125);
  expect(discountCents(5, const PercentOff(10)), 1); // 0.5 rounds up
});
test('accepts 0 and 100 percent, rejects outside', () {
  expect(discountCents(1999, const PercentOff(0)), 0);
  expect(discountCents(1999, const PercentOff(100)), 1999);
  expect(() => discountCents(1999, const PercentOff(-1)), throwsArgumentError);
  expect(() => discountCents(1999, const PercentOff(101)), throwsArgumentError);
});

The first block reports the same coverage as the second. However, isA<int>() passes for any integer at all, so a discount that rounds the wrong way, or takes 10 percent of the wrong number, sails through.

The service tests split the same way. The interaction suite asserts what the service did to its collaborators, while the spec suite asserts what reached the outside world:

// interaction_test.dart: the call sequence is the assertion.
verifyInOrder([
  () => accounts.find('acc-1'),
  () => plans.find('basic'),
  () => plans.find('storage'),
  () => gateway.charge('acc-1', 1870),
  () => invoices.save(any(), InvoiceStatus.paid),
]);

// spec_test.dart: the charge and the saved record are the assertion.
expect(await service(gateway).renew('acc-1'), InvoiceStatus.paid);
expect(gateway.charges, [('acc-1', 1870)]);
final (invoice, status) = invoices.saved.single;
expect(invoice.lines.map((l) => l.sku), ['basic', 'storage']);
expect(status, InvoiceStatus.paid);

Both check the charge amount. The difference is that the first one also pins down which repository method produced the plans, which is an implementation detail the spec never mentions. That difference decides one of the safe-change results below.

How the Mutants Were Generated

A mutant is a copy of the code with one small, deliberate bug. If at least one test fails, the suite killed it; if every test passes, the mutant survived, and the suite would not have noticed that bug in a real change. A small runner generates mutants mechanically from the library source, using the same families of operators as PIT and Stryker:

// tool/mutate.dart (excerpt): binary operators only match with spaces around
// them, so generic type arguments like List<int> are never mutated.
const swaps = {
  ' < ': ' <= ', ' <= ': ' < ', ' > ': ' >= ', ' >= ': ' > ',
  ' == ': ' != ', ' != ': ' == ', ' && ': ' || ', ' || ': ' && ',
  ' + ': ' - ', ' - ': ' + ', ' * ': ' + ', ' ~/ ': ' * ', ' % ': ' ~/ ',
  '++': '--',
  'InvoiceStatus.paid': 'InvoiceStatus.failed',
  'InvoiceStatus.failed': 'InvoiceStatus.paid',
  'InvoiceStatus.free': 'InvoiceStatus.paid',
  '.trim()': '', '.toUpperCase()': '', ".replaceAll('-', '')": '',
  'await ': '',
};
// Plus: remove a unary !, integer literal n -> n + 1, every if condition
// replaced by true and by false, and every call-only statement deleted.

String literals and comments are masked before matching, so a mutant never edits text inside a message. Each mutant replaces one line, runs all six suites in one dart test -r json process, records which suites failed, and restores the file. The run produced 114 mutants, and 6 of them did not compile, which leaves 108 scored ones.

One mutant needed special handling. M104 turns attempt++ into attempt-- in the retry loop, and with a gateway that keeps timing out, the loop never ends. Because every await in it completes immediately, the test runner’s own 15-second timeout never gets a turn, so the runner kills the process after 90 seconds instead. That kill cannot tell which suites were still running, so M104 was re-run against each suite alone. The coverage and interaction suites spin forever, which a CI timeout would report as a failure, while the mirror suite passes, and its cell was corrected to survived.

The Two Other Measurements

Line and function coverage come from package:coverage, one suite at a time:

#!/usr/bin/env bash
# tool/coverage.sh: line and function coverage of lib/ for each suite on its own.
set -euo pipefail
for suite in mirror coverage spec property interaction snapshot; do
  rm -rf "coverage/$suite"
  dart run coverage:test_with_coverage --branch-coverage --function-coverage \
    --out "coverage/$suite" --test "test/${suite}_test.dart" > /dev/null
  awk -v s="$suite" -F: '
    /^LF/ {lf+=$2} /^LH/ {lh+=$2} /^FNF/ {ff+=$2} /^FNH/ {fh+=$2}
    END {printf "%-12s lines %3d/%3d %5.1f%%  functions %2d/%2d\n",
      s, lh, lf, 100*lh/lf, fh, ff}' "coverage/$suite/lcov.info"
done

Branch coverage was requested too, but the collector returned no branch records on this setup, so only line and function coverage are reported.

The third measurement asks the opposite question. A suite that fails on every change is not safe, it is noisy, and a noisy suite trains a team to re-record and move on. Four changes that keep the spec intact were applied one at a time, with every suite run after each:

ChangeWhat it doesShould any test fail?
S1Renames the two internal helpersNo, both are hidden from the public API
S2Adds a currency key to the invoice JSONNo, it is additive
S3Rewords one ArgumentError messageNo, the error type is unchanged
S4Loads plans with one findMany call instead of a find per planNo, same invoice and same charge

AI Test Coverage vs Mutation Score, Measured

Here is the coverage output, followed by the mutation summary:

bash tool/coverage.sh
mirror       lines  72/ 92  78.3%  functions 20/22
coverage     lines  92/ 92 100.0%  functions 21/22
spec         lines  79/ 92  85.9%  functions 20/22
property     lines  75/ 92  81.5%  functions 20/22
interaction  lines  44/ 92  47.8%  functions 13/22
snapshot     lines  86/ 92  93.5%  functions 21/22
dart run tool/mutate.dart run mutants.jsonl
dart run tool/report.dart
mutants generated 114, did not compile 6, scored 108
hung (killed by the 90 s limit): M104

suite         killed  score   only this suite
mirror         56/108   51.9%  
coverage       48/108   44.4%  M1 M4 M8
spec           91/108   84.3%  
property       82/108   75.9%  M2 M83
interaction    41/108   38.0%  
snapshot       66/108   61.1%  

mirror + coverage                    70/108  64.8%
spec + property                      93/108  86.1%
spec + property + snapshot           93/108  86.1%
all six                              96/108  88.9%

survived every suite:
  M15 pricing.dart:28 [<] if (monthlyCents <= 0) throw ArgumentError.value(monthlyCents, 'monthlyCents');
  M16 pricing.dart:28 [literal] if (monthlyCents < 1) throw ArgumentError.value(monthlyCents, 'monthlyCents');
  M18 pricing.dart:28 [if->false] if (false) throw ArgumentError.value(monthlyCents, 'monthlyCents');
  M43 pricing.dart:46 [<] final day = anchor.day <= lastDay ? anchor.day : lastDay;
  M54 pricing.dart:78 [<] if (cents <= 0) throw ArgumentError.value(cents, 'cents');
  M55 pricing.dart:78 [literal] if (cents < 1) throw ArgumentError.value(cents, 'cents');
  M58 pricing.dart:79 [>] return cents >= subtotalCents ? subtotalCents : cents;
  M62 pricing.dart:85 [if->false] if (false) throw ArgumentError.value(basisPoints, 'basisPoints');
  M97 renewal.dart:75 [await] invoices.save(invoice, InvoiceStatus.free);
  M101 renewal.dart:80 [await] invoices.save(invoice, status);
  M112 renewal.dart:90 [if->false] if (false) return InvoiceStatus.failed;
  M114 renewal.dart:95 [InvoiceStatus.failed] return InvoiceStatus.paid;
did not compile: M85 M86 M87 M88 M89 M100

The mutation run is deterministic: the property suites use fixed seeds, so the same mutant always produces the same outcome. The full pass over 114 mutants took 268 seconds in a single run.

The Ranking at a Glance

SuiteLine coverageMutants killedSafe changes that broke itMedian run
coverage100.0%44.4%2 of 4591 ms
snapshot93.5%61.1%2 of 4348 ms
spec85.9%84.3%0 of 4508 ms
property81.5%75.9%0 of 4394 ms
mirror78.3%51.9%3 of 4401 ms
interaction47.8%38.0%1 of 4353 ms

Sorted by coverage, the suite that ran every line comes first. Sorted by bugs caught, it comes fifth of six, ahead only of the interaction suite, which does not test most of the library at all. In other words, the AI test coverage number ranked these suites almost backwards.

The run times are the median of five runs of each suite, read from the JSON reporter’s final timestamp in milliseconds:

for s in mirror coverage spec property interaction snapshot; do
  printf "%-12s" $s
  for i in 1 2 3 4 5; do
    dart test -r json test/${s}_test.dart | tail -1 | grep -o '"time":[0-9]*' | cut -d: -f2 | tr '\n' ' '
  done; echo
done
mirror      463 376 374 401 405 
coverage    437 509 797 736 591 
spec        717 767 508 456 444 
property    406 394 402 392 384 
interaction 376 364 353 351 353 
snapshot    348 343 350 344 352 

Most of each figure is test runner startup, so the gaps between them mean little. Speed is not what separates these suites.

Scores per File

Because the interaction suite only targets the renewal service, the fair comparison is per file:

dart run tool/by_file.dart
pricing.dart   70 mutants  mirror 41  coverage 34  spec 58  property 52  interaction 15  snapshot 41
invoice.dart   12 mutants  mirror 9  coverage 9  spec 12  property 9  interaction 7  snapshot 9
renewal.dart   26 mutants  mirror 6  coverage 5  spec 21  property 21  interaction 19  snapshot 16

On its own ground, the interaction suite killed 19 of 26 renewal mutants, close to the spec and property suites at 21 each. So mocks are not useless at catching bugs in orchestration code. Their problem shows up in the next measurement. Meanwhile, the coverage-chaser killed 5 of 26 renewal mutants even though it ran every line of that file, including both failure paths.

What Combining Suites Adds

The two suites an agent produces for free, mirror and coverage, together reach 100 percent of lines and 64.8 percent of mutants. That is the ceiling of AI test coverage bought without a spec: every line, and about two thirds of the bugs. In contrast, spec and property tests together kill 86.1 percent. Adding the snapshot suite to those two adds nothing: 93 mutants before, 93 after. Every bug the golden files caught, the spec or property tests had already caught.

The “only this suite” column is worth a look as well. The coverage suite’s three unique kills, M1, M4 and M8, are all in the guards of the internal rounding helper, which it calls directly from src/. No public-API test exercises those guards, because the callers validate their inputs first. That direct import is a real gain on this bench, and it is also the reason the same suite stopped compiling after the S1 rename.

Which Suites Broke on Changes That Should Not Break Them

dart run tool/safe_changes.dart
S1 rename the two internal helpers
  coverage_test.dart     does not compile
  interaction_test.dart  0 of 5 tests fail
  mirror_test.dart       does not compile
  property_test.dart     0 of 7 tests fail
  snapshot_test.dart     0 of 3 tests fail
  spec_test.dart         0 of 32 tests fail
S2 add a 'currency' key to the invoice JSON
  coverage_test.dart     0 of 13 tests fail
  interaction_test.dart  0 of 5 tests fail
  mirror_test.dart       0 of 14 tests fail
  property_test.dart     0 of 7 tests fail
  snapshot_test.dart     2 of 3 tests fail
  spec_test.dart         0 of 32 tests fail
S3 reword one ArgumentError message
  coverage_test.dart     0 of 13 tests fail
  interaction_test.dart  0 of 5 tests fail
  mirror_test.dart       1 of 14 tests fail
  property_test.dart     0 of 7 tests fail
  snapshot_test.dart     0 of 3 tests fail
  spec_test.dart         0 of 32 tests fail
S4 load plans with one findMany call instead of a find per plan
  coverage_test.dart     5 of 13 tests fail
  interaction_test.dart  4 of 5 tests fail
  mirror_test.dart       1 of 14 tests fail
  property_test.dart     0 of 7 tests fail
  snapshot_test.dart     1 of 3 tests fail
  spec_test.dart         0 of 32 tests fail

The spec and property suites survived all four changes without a single false failure. The other four suites each broke at least once, and every break traces to the same cause: a test that knows something the spec does not say.

The mirror and coverage suites import helpers from src/, so renaming them is a compile error rather than a test failure, and nothing in the suite runs at all. Next, the mirror suite asserts an error message word for word, which turns a wording change into a red build. The snapshot suite fails on an additive JSON key, because a golden file cannot tell a new field from a broken one. Finally, S4 is the canonical over-mocking failure: the stubs answer find, the code now calls findMany, and mocktail answers an unstubbed call with null, which fails as a type error. As a result, four of five interaction tests fail on a change that produces the same invoice and the same charge. The guide to mocking external dependencies covers where that line sits in general.

This matters more after AI than before. A team where an agent refactors code daily runs into these false failures daily, and the cheapest fix is to have the same agent update the test until it passes. At that point the test checks nothing, because it was rewritten to match whatever the code now does.

Results That Cut Against Expectations

The 100% Coverage Suite Ran the Worst Bug and Passed

M114 changes the fallback at the end of the retry loop from failed to paid. That line only runs when maxAttempts is zero, which is a configuration mistake, not an everyday input. With the mutant in place, a service configured with zero attempts marks every invoice paid without ever calling the gateway.

The coverage-chaser is the only suite that executes that line, because it set out to reach 100 percent and found the branch. Here is its test:

test('zero attempts', () async {
  when(() => gateway.charge(any(), any())).thenAnswer((_) async => 'ch');
  final none = RenewalService(accounts, plans, invoices, gateway,
      maxAttempts: 0);
  expect(await none.renew('a'), isA<InvoiceStatus>());
});

It ran the exact line containing the bug and asserted that the result was some status. As a result, the coverage report shows that line green, and the mutant survived every suite. A coverage report can only show that a test reached a line, never that anything checked what the line did.

Snapshots Added Nothing, Then Certified a Bug

The snapshot suite killed 66 mutants, more than the mirror or coverage suites. However, every one of them was also killed by the spec or property suite, so on top of those two it contributed zero kills and two false failures.

The second problem shows up when the golden files get re-recorded, which is the routine response to a snapshot failure. To test that, the classic month-end bug was injected into nextBillingDate, returning DateTime.utc(year, month, anchor.day) so that Jan 31 plus one month rolls over into March. The snapshot suite failed, as it should. Then the goldens were re-recorded the way a busy reviewer would:

UPDATE_GOLDENS=1 dart test test/snapshot_test.dart 2>&1 | tail -1
diff ../goldens.bak/pricing.json test/goldens/pricing.json
dart test -r expanded 2>&1 | grep -E "\[E\]|Some tests|All tests" | sed 's/^[0-9:+ -]*//'
00:00 +3: All tests passed!
12c12
<     "2026-02-28T00:00:00.000Z",
---
>     "2026-03-01T00:00:00.000Z",
14,15c14,15
<     "2027-02-28T00:00:00.000Z",
<     "2026-02-28T00:00:00.000Z",
---
>     "2027-03-01T00:00:00.000Z",
>     "2026-03-02T00:00:00.000Z",
17,18c17,18
<     "2027-02-28T00:00:00.000Z",
<     "2026-02-28T00:00:00.000Z",
---
>     "2027-03-02T00:00:00.000Z",
>     "2026-03-03T00:00:00.000Z",
20c20
<     "2027-02-28T00:00:00.000Z"
---
>     "2027-03-03T00:00:00.000Z"
test\mirror_test.dart: nextBillingDate clamps Jan 31 to Feb 28 [E]
test\property_test.dart: nextBillingDate matches a month-by-month reference [E]
test\spec_test.dart: nextBillingDate clamps to the end of a short month [E]
test\spec_test.dart: nextBillingDate uses Feb 29 in a leap year [E]
test\spec_test.dart: nextBillingDate crosses a year boundary [E]
Some tests failed.

After re-recording, the snapshot suite passes with the bug written into its golden file, and the diff shows exactly what got certified: customers anchored on the 29th, 30th or 31st now bill in March instead of February. The spec, property and mirror suites still fail. The coverage suite, notably, passes with the bug in place, because its date test only checks that Jan 10 plus one month lands in month 2. The snapshot testing guide covers when golden files are the right tool, and this run shows the failure mode it warns about.

The Mirror Suite Caught the Month-End Bug

That same run produced a surprise in the other direction. The mirror suite, which copied its expected values from the implementation, still caught the month-end bug. The reason is that the spec comment names Jan 31 plus one month as an example, so a suite written by reading the file picked it up.

The mirror suite caught it only because the example was written into the doc comment. Where the comment named no boundary, the mirror suite tested one typical input and moved on. Its 51.9 percent score is the result. Consequently, a mirror-style suite is only as good as the examples the code author left lying around, and it can never catch a bug the author also wrote into the examples.

Random Inputs Missed Zero

The property suite ran 3,500 random cases and still missed four mutants that a single literal would have caught. M15 and M16 make prorateCents reject a monthly price of zero, so prorating a free plan throws. Similarly, M54 and M55 make a fixed discount of zero cents throw.

The generators draw prices from rng.nextInt(100000) and fixed discounts from similarly wide ranges, so any single case has roughly a one in 100,000 chance of producing zero. Across 500 cases per rule, a zero never came up. Meanwhile, the spec suite missed them because the spec says “throws when negative” and never says “zero is fine”, so no test names zero. Property tests are strongest when their generators deliberately include the boundaries, such as 0, 1 and the maximum, rather than relying on luck to find them.

Nothing Caught the Unawaited Save

M97 and M101 remove the await in front of invoices.save. In production, that means a failed write is never observed, and renew can report paid before the invoice exists. Every suite missed both, including the interaction suite, because a mock records the call whether or not anyone awaited it. Likewise, the in-memory fakes complete instantly, so the missing await changes nothing in a test.

No test shape on this bench catches that class of bug. The unawaited_futures lint catches it before any test runs. That is the same conclusion the earlier measurement on reviewing AI-written code reached: some defects belong to static analysis, and no amount of test writing replaces it.

The Twelve Survivors, Read by Hand

A mutation score is only honest after the survivors are read, because some mutants cannot be told apart from the original by any test. Those are called equivalent mutants. Here is the classification of all twelve:

MutantsChangeVerdict
M43, M58< to <= in a min-style comparison, where both branches return the same value when equalEquivalent
M112Removes the early return failed after the last timeout; the loop ends and the fallback returns failed anywayEquivalent
M18, M62Removes the negative-price and negative-tax guards; the rounding helper still throws ArgumentError for a negative input unless the other factor is zeroNearly equivalent
M15, M16, M54, M55Rejects a price of zero or a fixed discount of zeroReal gap: no test names zero
M97, M101Unawaited invoice saveReal gap: no test can observe it, the lint can
M114Zero attempts returns paid without chargingReal gap: the line ran, nothing checked it

Excluding the three equivalent mutants leaves 105. On that basis, spec and property tests together kill 93, or 88.6 percent, and all six suites kill 96, or 91.4 percent. The remaining real gaps are fixable with two more spec tests (zero price, zero discount), one more spec test for zero attempts, and one lint rule.

Which Tests Are Worth Writing When an Agent Writes the Code

Ranked by bugs caught and false failures together, the order on this bench is:

  1. Spec tests, one per sentence of the spec. Highest mutation score (84.3%), zero false failures, written against the public API only.
  2. Property tests for anything with arithmetic, dates or retries. Second-highest score (75.9%), zero false failures, and two unique kills, but only with boundary values added to the generators.
  3. Outcome tests with in-memory fakes for collaborators. They are part of the spec suite here: assert the charge, the saved record and the returned status, not the call sequence.
  4. A static analysis rule for each defect class tests cannot observe. Unawaited futures, swallowed exceptions and unused results belong to the analyzer.
  5. Snapshots only for formats an outside consumer parses. A public API response or an exported file, where any change really is worth a human look.
  6. Mirror, coverage-chasing and call-verifying tests, only as temporary scaffolding. They are useful as a safety net during a refactor, as long as they get deleted afterwards rather than maintained.

Put differently, the tests worth writing by hand, or worth reviewing hard when an agent writes them, are the ones that encode intent. Everything that can be derived from the implementation, an agent can regenerate in seconds. Those tests still raise AI test coverage, but they carry no information the code does not already have.

How to Ask an Agent for Tests That Kill Mutants

Agents follow the instruction they get. Asking for AI test coverage gets coverage, while asking for a rule gets a test of the rule. Most of the difference between the coverage suite and the spec suite is therefore in the prompt. A request shaped like this one produces the spec shape instead of the mirror shape:

Write tests for nextBillingDate and prorateCents from the spec below, not
from the implementation. Import only package:billing_bench/billing.dart.
One test per sentence of the spec. Every numeric rule needs a test at 0, at
1, at the boundary named in the spec, and one step past it. Assert exact
values, never isA, isNotNull or greaterThan. Do not assert error message text.

<paste the doc comments, or the ticket, here>

Two details carry most of the weight. Giving the spec rather than the file keeps the agent from copying current behavior into the expected values. In addition, banning type-only matchers removes the assertion style that let M114 survive. After the tests pass, a mutation run on the changed file tells both the agent and the reviewer which rules still have no test. Pasting the survivors back into the conversation is an effective second prompt, because each one is a concrete bug the suite would have missed.

The same idea fits a hook. A Claude Code hook that runs tests on every change can run a mutation pass on changed files instead of a coverage report, which feeds the agent survivors rather than a percentage. For the broader trade-offs of letting a model write tests at all, see generating unit tests with large language models.

Running Mutation Testing on Other Stacks

The runner used here is about 190 lines because the bench needed per-suite attribution. For a real codebase, a maintained tool is the better choice:

StackMutation toolNotes
JavaScript, TypeScriptStrykerJSIncremental mode re-tests only changed code, which keeps CI times reasonable
C#, ScalaStryker.NET, Stryker4sSame mutant states and scoring as StrykerJS
Java, Kotlin on the JVMPITThe documented mutator list maps directly to the operators above
PythonmutmutWorks with pytest
Rustcargo-mutantsNeeds no changes to the test code
Dart, Fluttermutation_testConfigurable rule-based mutations

Whichever tool fits, start with changed files on pull requests rather than the whole codebase. Mutation runs scale with mutants times test time, and a full pass on a large service can take hours. Google’s paper describes exactly this diff-based approach, and it is what makes the signal affordable.

A Real Scenario: The Coverage Gate That Rewarded the Wrong Tests

Consider a backend team of six to ten engineers that adopted a coding agent and, at the same time, raised its CI coverage gate from around 70 percent to 90 percent. Over a few weeks the number climbs past the gate on almost every service, mostly through tests the agent writes alongside each change. Review gets faster, because a green check on AI test coverage reads as diligence.

The first sign of trouble is that refactors get slower, not faster. Renaming an internal function breaks dozens of tests that import it directly, and moving a repository call breaks mock setups across several files. The agent fixes those failures by rewriting the tests to match the new code, which keeps the build green. Meanwhile, escaped defects do not fall, because the new tests assert types and call sequences rather than the business rules that change most often.

The fix that tends to work is to change what the gate measures rather than to raise or lower the number. The team keeps AI test coverage as a report but stops failing builds on it. Instead, it runs incremental mutation testing on changed files and asks for no new survivors in the lines a pull request touches. The trade-off is real: CI gets slower by minutes on large diffs, and someone has to triage equivalent mutants. On the other hand, the gate now fails for the M114 kind of gap, and a suite that only runs lines can no longer pass it.

When to Use a Coverage Target

  • As a report that shows code no test reaches at all, since 0 percent on a file is still a reliable warning
  • On new modules where untested files are the more likely problem than weak assertions
  • Alongside a mutation score, where coverage explains why a mutant survived (never reached versus reached but unchecked)
  • For finding dead code, since a line no realistic test can reach is often a line nobody needs

When NOT to Use a Coverage Target

  • As a build gate on code where an agent writes the tests, because the agent meets it with tests like the coverage suite here
  • As evidence that a change was tested, since the 100 percent suite here caught 44.4 percent of the mutants
  • As a team or individual performance metric, which rewards exactly the shapes that scored lowest
  • On orchestration code verified only through mocks, where high coverage coexists with false failures on every refactor

Common Mistakes with AI Test Coverage

  • Asking an agent to “increase coverage” instead of “test this rule”, which produces execution without assertion
  • Letting generated tests import from src/ and test private helpers, which turns every rename into a compile error
  • Accepting isA, isNotNull and greaterThan(0) as assertions on values the spec defines exactly
  • Re-recording snapshots on failure without reading the diff, which certifies whatever bug caused the failure
  • Having the agent “fix” a failing test by matching the new output, which deletes the test’s meaning while keeping its line count
  • Reading a mutation score without reading the survivors, since some are equivalent and some, like M114, are the most important result in the report
  • Treating random generators as boundary tests, when a zero price never came up in 3,500 random cases here

For more on why the number misleads even without agents in the picture, the earlier guide to code coverage metrics and when they lie covers the general case.

What This Measurement Does Not Cover

The six suites were written for this bench to represent common shapes. They were not sampled from any model’s output, so the bench shows what each shape catches, not how often an agent produces it. Moreover, the same author wrote the library, the suites and the mutation operators, which may favor whichever shape the author understood best.

The library is 195 lines of code with no I/O, no concurrency and no UI, so the numbers say nothing about widget, integration or end-to-end tests. The mutants are mechanical, one operator at a time, and real bugs are often larger: a misread requirement or a missing feature will not show up as a flipped <. In addition, branch coverage could not be collected, and the four safe changes are a small sample of the refactors a real codebase goes through. Finally, nothing here measures the time it takes to write each shape by hand or by agent, which depends on the team and the tool.

Conclusion

AI test coverage measures how much code ran, and on this bench that ranked six suites almost backwards. The suite with 100 percent line coverage killed 44.4 percent of 108 mutants and broke on two of four harmless changes. Meanwhile, spec tests reached 85.9 percent coverage, killed 84.3 percent of mutants and broke on none. Spec and property tests together caught 86.1 percent, and snapshots added nothing on top of them.

Start this week by changing one instruction: ask the agent to test a written rule through the public API, with exact values and named boundaries, instead of asking it to raise coverage. Then run a mutation tool on the files the next pull request touches and read the survivors, which is the review the coverage number was supposed to replace.