Mutation Testing & Assertion Quality
Coverage tells you which lines ran. It says nothing about whether anything would have noticed if those lines were wrong. A suite can execute every branch of a pricing function and still pass when you change > to >=, because the tests called the code without ever checking the answer. Mutation testing closes that gap directly: it changes your source in small, deliberate ways and reports which changes your tests failed to catch. Each surviving change is a concrete, reproducible statement that a real bug of that shape could ship. This topic sits under test pyramid strategy alongside defining coverage thresholds, and it is the technique that turns a coverage number from a compliance metric into a statement about detection.
Architectural Scope & Boundaries
Mutation testing belongs to the unit tier, and mostly to logic-dense code: pricing, permissions, validation, date arithmetic, state machines, parsers. These are the places where a one-character change produces a wrong answer rather than a crash, and where the cost of an undetected defect is high. It is not a whole-suite technique. Running it across an entire application is slow and produces a wall of results dominated by code where mutation says little — glue, configuration, presentational components.
The boundary against end-to-end and component tests is practical rather than philosophical. Mutation testing runs the suite once per surviving mutant, so the total cost is roughly the number of mutants multiplied by the time to run the tests that touch them. With fast unit tests that is minutes; with a browser-based suite it is hours. Point the tool at your pure logic and let the other tiers be judged by different measures.
There is a third boundary worth naming, because teams trip over it: mutation testing measures your tests, not your code. A low score does not mean the module is badly written, and a high score does not mean it is correct — a suite can kill every mutant while asserting the wrong behaviour consistently. What the score does tell you, reliably, is how much of your implementation could silently change without anything objecting. That is a narrower claim than “quality”, and it is far more actionable, because every survivor names a file, a line and the exact change that went unnoticed.
It also does not replace review or types. A mutation that survives because the code is dead, or because two branches are genuinely equivalent, is telling you something about the source rather than the tests. Those cases — equivalent mutants — are the known cost of the technique, and the practical answer is to accept a target below 100 rather than to chase every survivor.
One more framing helps when introducing this to a team. Engineers who have only seen coverage numbers tend to read a mutation score as a harsher coverage percentage, and react to a first result of 60% as a failure. It is not: 60% on a first run over real business logic is an ordinary starting point, and the useful output is not the number at all but the list of survivors. Present the first report as a to-do list of specific weak tests, and the technique lands as a tool rather than an accusation.
Prerequisites
Step-by-Step Implementation
Step 1 — Install Stryker with the Vitest runner. The runner plugin matters: Stryker needs to drive your actual test framework in-process to keep per-mutant cost down.
npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
Step 2 — Configure a narrow first run. Point mutate at genuine logic, not at everything. A first report over two hundred well-chosen mutants is far more useful than one over eight thousand.
// stryker.config.mjs
export default {
packageManager: 'npm',
testRunner: 'vitest',
reporters: ['html', 'clear-text', 'progress'],
coverageAnalysis: 'perTest',
mutate: ['src/domain/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts'],
thresholds: { high: 85, low: 70, break: 60 },
concurrency: 4,
};
The coverageAnalysis: 'perTest' setting deserves particular attention, because it is what makes the whole technique affordable. With it, Stryker first runs your suite once with instrumentation to learn which tests touch which lines, then runs only those tests for each mutant. Without it, every mutant runs the entire suite, and the cost goes from minutes to hours on even a modest project. The trade-off is that it requires tests to be independent: if test B only passes because test A ran first, the subset chosen for a mutant may behave differently from the full run, producing results that shift between runs.
Step 3 — Read the score as three numbers, not one. Stryker reports killed, survived and no-coverage mutants. Survivors in covered code are the interesting ones: the tests ran and did not notice. Mutants with no coverage are an ordinary coverage gap and are cheaper to fix by writing a test at all.
npx stryker run
# Mutation score: 74.18%
# killed 412
# survived 91 ← tests ran, nothing failed
# no coverage 53 ← no test touched this at all
# timeout 11
The three categories want three different responses. Mutants with no coverage are the cheapest to address and the least interesting: write any test that exercises the code and they turn into killed or survived, at which point you learn something. Survivors in covered code are the real output of the run — each one is a test that executed the line and did not care what it did. Timeouts sit in between; they usually indicate a loop whose bound was mutated, and they count as killed for scoring purposes even though no assertion did the work.
Step 4 — Fix survivors by strengthening assertions, not by adding tests. The usual survivor is a test that calls a function and checks only that it did not throw. Adding a second such test changes nothing; asserting on the value kills the mutant and every future mutant like it.
// survivor: changing `>` to `>=` in applyDiscount does not fail this test
test('applies a discount', () => {
expect(() => applyDiscount(cart, 100)).not.toThrow();
});
// kills it: the boundary is now pinned
test.each([
[99, 0],
[100, 0],
[101, 10.1],
])('discounts an order of %i by %f', (total, expected) => {
expect(applyDiscount({ ...cart, total }, 100).discount).toBeCloseTo(expected);
});
Notice what changed between those two tests. The first exercises the function, contributing to line coverage and nothing else. The second pins the behaviour at the boundary — one unit below the limit, exactly at it, one above — which is precisely where the mutated operator would differ. This is the general shape of the fix: survivors cluster at boundaries and at branch conditions, and the assertions that kill them are the ones a careful reviewer would have asked for anyway.
Step 5 — Put it on a schedule rather than in the merge gate, at first. A nightly run over a defined subset gives the team a trend without lengthening every pull request. Once the score is stable, a break threshold can gate merges for the same subset.
# .github/workflows/mutation.yml
on:
schedule: [{ cron: '0 3 * * *' }]
workflow_dispatch:
jobs:
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx stryker run
- uses: actions/upload-artifact@v4
with: { name: mutation-report, path: reports/mutation }
Step 6 — Wire the report where people will actually see it. A mutation report that lives in a CI artifact nobody downloads changes no behaviour. Publish the HTML output to a static location and post the score as a comment or a check summary, so the number sits next to the change that moved it.
// scripts/mutation-summary.ts — turn the JSON report into one line for CI
import { readFileSync, appendFileSync } from 'node:fs';
const report = JSON.parse(readFileSync('reports/mutation/mutation.json', 'utf8'));
const all = Object.values(report.files).flatMap((f: any) => f.mutants);
const killed = all.filter((m: any) => m.status === 'Killed').length;
const survived = all.filter((m: any) => m.status === 'Survived');
const score = ((killed / (killed + survived.length)) * 100).toFixed(2);
const worst = survived.slice(0, 5).map((m: any) => `- ${m.location.start.line}: ${m.mutatorName}`);
appendFileSync(process.env.GITHUB_STEP_SUMMARY!, `## Mutation score ${score}%
${worst.join('
')}
`);
The five worst survivors in the job summary are worth more than a score in a log, because they are the only part of the report that is directly actionable without opening anything.
Configuration Reference Table
| Option | Type | Default | Effect |
|---|---|---|---|
mutate |
string[] | all source | The single biggest cost dial; narrow it to logic-dense directories. |
coverageAnalysis |
enum | perTest |
perTest runs only covering tests per mutant — usually an order of magnitude faster. |
concurrency |
number | cores ÷ 2 | Parallel mutant runners; each needs memory for a full test process. |
thresholds.break |
number | none | Exits non-zero below this score; leave unset until a baseline exists. |
timeoutMS |
number | 5000 | Guards infinite loops created by a mutant; too low classifies slow tests as timeouts. |
ignorePatterns |
string[] | build dirs | Keeps Stryker’s sandbox copy small, which speeds up every run. |
incremental |
boolean | false |
Reuses the previous report so only changed files are re-tested. |
disableTypeChecks |
boolean | string | true |
Mutants often break types deliberately; leave enabled or every mutant fails to compile. |
A note on disableTypeChecks: mutation deliberately produces code that does not type-check, such as replacing a string return with an empty one where the signature says otherwise. Leaving type checking enabled makes every such mutant fail to build, which Stryker counts as killed and which quietly inflates the score for reasons that have nothing to do with your tests. The default of disabling checks inside the sandbox is almost always what you want.
Verification & Assertions
Verify the tool before you trust its report. Introduce a deliberate weakness — delete an assertion from a well-covered test — and confirm the score drops and the survivor points where you expect.
npx stryker run --mutate "src/domain/pricing.ts"
# Mutation score: 61.90% (was 92.30%)
# Survived: src/domain/pricing.ts:41:14 — ConditionalExpression
The HTML report is the working surface, not the number. It shows each mutant in place with the code around it, which is what turns “the score is 74” into a list of specific, fixable gaps. Read it file by file and treat every survivor as a question: if this line were wrong in this way, would we ship it?
Finally, confirm the run is deterministic. Two runs over the same commit must produce the same score; variation means tests are order-dependent or timing-sensitive, which is a suite problem that eliminating test order dependence addresses directly.
It is worth being explicit about what the report cannot tell you. A killed mutant means some test failed, not that the right test failed for the right reason — a snapshot test that breaks on every change kills mutants indiscriminately. Reading a handful of killed mutants alongside the survivors, at least on the first run, tells you whether the kills are coming from meaningful assertions or from blanket comparisons, and that distinction is what determines whether the score is measuring anything you care about.
Edge Cases & Failure Modes
Equivalent mutants. Some changes produce code that behaves identically — replacing < with != in a loop whose bound is always reached, for instance. No test can kill them, and hunting them wastes time. Diagnose by reading the mutant in context; if it genuinely cannot change behaviour, exclude it with a comment and move on.
Timeouts misread as kills. A mutant that creates an infinite loop is reported as a timeout, which counts toward the score but is not the same as being detected by an assertion. A file with many timeouts deserves a look: it often means the code has loops whose bounds depend on the mutated expression.
Runaway cost from a broad mutate glob. Including generated clients, migrations or bundled vendor code multiplies the mutant count without adding signal. Narrow the glob rather than raising concurrency; the fastest mutant is the one never generated.
A high score from tautological tests. Snapshot assertions kill mutants readily because any change alters the snapshot, which can flatter the score while telling you little about intent. Treat a sudden jump after a snapshot-heavy change with suspicion, and prefer explicit assertions in logic-dense code.
String and boolean literal mutations in user-facing copy. Stryker will happily change an error message and report that no test noticed. Whether that matters is a judgment call: a message that users rely on may deserve an assertion, while an internal log line almost certainly does not. Rather than debating each one, exclude presentational strings from the glob so the report stays full of mutants that represent real risk.
Performance & CI Impact
Budget by mutant count. With coverageAnalysis: 'perTest', each mutant runs only the tests that cover it, so a typical unit suite handles a mutant in tens to hundreds of milliseconds. Two thousand mutants at 150 ms on four concurrent runners is about ninety seconds of work — nightly-friendly. The same two thousand mutants with coverageAnalysis: 'off' runs the whole suite each time and turns ninety seconds into hours.
Incremental mode is the other significant lever: with a stored report, a run after a small change re-tests only the affected files, which is what makes a per-pull-request mutation check feasible for a focused directory. Combine it with a narrow glob and the check costs less than a browser test.
Treat the score as a trend, not a gate, until it is stable. A break threshold set on the first report will either be so low it means nothing or so high it blocks unrelated work. Watch it for a few weeks, set the threshold just below the observed floor, and raise it deliberately — the same discipline described in defining coverage thresholds.
One organisational note completes the picture. Mutation results age quickly, because both the source and the tests move under them, so a report older than a week is a historical curiosity rather than a work list. Teams that get value from this technique run it on a schedule, publish the report where people already look, and work survivors in the module they happen to be changing rather than scheduling a “mutation sprint” that never comes. Treated that way it becomes a steady ratchet on assertion quality, which is exactly the kind of measure worth tracking alongside the numbers in test observability and reporting.
In-Depth Guides
- Running Stryker mutation testing with Vitest — a working configuration and first report from scratch.
- Finding weak assertions with mutation scores — read survivors and turn each into a sharper test.
- Keeping mutation testing fast in CI — incremental runs, concurrency and scoping that keep the job affordable.
- Choosing mutation score targets per module — different bars for domain logic, adapters and UI.
Related
- Back to Modern JavaScript Test Strategy & Pyramid Design
- Defining Coverage Thresholds — the metric mutation testing corrects for.
- Why 100% coverage is the wrong target — the argument this topic gives teeth to.
- Cost-Benefit Analysis of Test Layers — where the time for stronger unit tests comes from.
Running Stryker Mutation Testing With Vitest
Install Stryker with the Vitest runner, scope the first run to real logic, read the HTML report, and get a trustworthy mutation score in minutes rather than hours.
Finding Weak Assertions With Mutation Scores
Turn surviving mutants into sharper tests: five assertion smells mutation testing exposes, how to fix each, and how to spot equivalent mutants.
Keeping Mutation Testing Fast in CI
Make mutation runs affordable: incremental reports, scoping to changed files, tuning concurrency and timeouts, and nightly versus per-PR runs.
Choosing Mutation Score Targets Per Module
Set different mutation bars for domain logic, adapters and UI, based on observed floors rather than round numbers, and ratchet them safely.