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.

What line coverage measures versus what mutation testing measures Line coverage asks whether a line executed during the run; mutation testing asks whether changing that line makes a test fail. A line can be fully covered and still have no test that would notice it being wrong. Coverage asks did this line run? answers yes for a test that calls the function and asserts nothing at all Mutation testing asks would a change be caught? flips an operator, removes a call, changes a constant — then checks the suite goes red 100% coverage with 0% mutation score is a suite that executes code and verifies nothing
Coverage measures execution; mutation testing measures detection — only the second is what a test is for.

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);
});
How a mutant is generated, run and classified Stryker parses the source, generates a mutant by changing one operator or value, runs only the tests that cover that line, and classifies the mutant as killed when a test fails or survived when the suite stays green. source line total > limit mutant total >= limit covering tests run only those killed a test failed survived suite stayed green per-test coverage data is what keeps this affordable — only the relevant tests run per mutant
A survivor is a precise, reproducible claim: this change to your code ships without any test objecting.

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.

Where mutation testing pays for itself Logic-dense code such as pricing, permissions and validation yields high signal per mutant, while presentational components, configuration and glue code yield little, so the glob should target the first group. Signal per mutant Worth mutating pricing and discount rules permission checks validation and parsing Rarely worth it presentational components configuration objects generated clients
Narrowing the glob is the difference between a nightly run and a technique the team abandons.

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