Finding Weak Assertions With Mutation Scores
A mutation report is not a grade; it is a list of specific claims about your tests, each of which says “this change to your code would have shipped”. The work that follows a run is therefore not “raise the number” but “read each claim and decide whether you agree”. Most survivors turn out to be one of a small number of recurring assertion smells — tests that check a function did not throw, that something is truthy, that an object has a shape but not a value. This guide is for engineers who have a Stryker report in front of them and want a systematic way to work through it. It covers the five smells that account for most survivors, the rewrite for each, how to recognise an equivalent mutant so you stop chasing it, and how to keep the work proportionate. It follows on from running Stryker mutation testing with Vitest.
Root Cause Analysis
Weak assertions are not carelessness; they are what happens when a test is written to make coverage go up, or written before the behaviour was decided, or copied from a neighbour that was itself weak. They share one structural property: the assertion is satisfied by a large set of possible return values, so the mutated implementation stays inside that set. expect(result).toBeDefined() is satisfied by every value except undefined; expect(fn).not.toThrow() is satisfied by every non-throwing implementation, including one that returns nothing at all.
Mutation testing finds these precisely because it works from the opposite direction. It does not ask what the test checks; it asks what the test would tolerate. A survivor is the constructive proof that the tolerated set includes a wrong answer — and unlike a code review comment, it comes with the exact wrong answer attached, which makes the conversation about the test concrete rather than stylistic.
The second, less obvious cause is assertion placement. A test can have a perfectly precise assertion aimed at the wrong thing: it asserts on the input it passed in, on a mock’s call count rather than the result, or on a field the mutated line does not touch. These produce survivors that look puzzling until you notice the assertion and the mutated line are about different things.
Reproducible Setup
Work from a real report rather than from intuition. Generate the JSON alongside the HTML so survivors can be sorted and counted.
// stryker.config.mjs
export default {
testRunner: 'vitest',
coverageAnalysis: 'perTest',
mutate: ['src/domain/**/*.ts', '!src/**/*.test.ts'],
reporters: ['html', 'json', 'clear-text'],
jsonReporter: { fileName: 'reports/mutation/mutation.json' },
};
// scripts/survivors.ts — the survivor list, grouped by file and mutator
import { readFileSync } from 'node:fs';
const report = JSON.parse(readFileSync('reports/mutation/mutation.json', 'utf8'));
const rows = Object.entries(report.files).flatMap(([file, data]: [string, any]) =>
data.mutants
.filter((m: any) => m.status === 'Survived')
.map((m: any) => ({ file, line: m.location.start.line, mutator: m.mutatorName })),
);
const byMutator = rows.reduce<Record<string, number>>((acc, r) => {
acc[r.mutator] = (acc[r.mutator] ?? 0) + 1;
return acc;
}, {});
console.table(byMutator);
console.table(rows.slice(0, 20));
Grouping by mutator first is worth the extra minute: a report dominated by ConditionalExpression survivors points at missing boundary cases, while one dominated by StringLiteral survivors usually means the glob is including presentational copy that does not deserve assertions.
Implementation
Step 1 — Smell one: the test that only checks nothing exploded. Replace the absence of an exception with the presence of the right answer.
// before — survives every arithmetic and conditional mutant
test('calculates tax', () => {
expect(() => calculateTax(order)).not.toThrow();
});
// after — the value is pinned, so any change to the sum fails
test('charges 20% VAT on a standard-rated order', () => {
expect(calculateTax({ net: 50, rate: 'standard' })).toEqual({ vat: 10, gross: 60 });
});
Step 2 — Smell two: truthiness standing in for a value. toBeTruthy accepts 1, 'x', [] and {} alike, so any mutant that returns something is safe from it.
// before
expect(findUser('ada')).toBeTruthy();
// after
expect(findUser('ada')).toMatchObject({ id: 'ada', role: 'admin' });
Step 3 — Smell three: asserting on the mock instead of the outcome. Checking that a collaborator was called proves the wiring, not the behaviour, and survives any mutant that changes what is done with the result.
// before — passes even if the returned total ignores the repository entirely
expect(repo.findItems).toHaveBeenCalledWith('cart-1');
// after — the call is implied by the outcome being correct
const summary = await summariseCart('cart-1');
expect(summary).toEqual({ items: 3, total: 42.5 });
Step 4 — Smell four: boundaries tested only from one side. Conditional mutants survive when a test exercises a comparison well inside the range rather than at its edge.
// before — 150 and 50 never distinguish > from >=
test.each([[150, true], [50, false]])('isEligible(%i) → %s', (total, expected) => {
expect(isEligible(total)).toBe(expected);
});
// after — the two cases that pin the operator
test.each([[99, false], [100, false], [101, true]])(
'isEligible(%i) → %s',
(total, expected) => expect(isEligible(total)).toBe(expected),
);
Step 5 — Smell five: snapshots hiding intent. A large snapshot kills mutants but tells the next reader nothing about which part mattered, and it breaks on unrelated changes. Replace it with explicit assertions on the fields the code under test actually computes.
// before — updated reflexively whenever it fails
expect(renderInvoice(order)).toMatchSnapshot();
// after — states the intent, fails for one reason
const invoice = renderInvoice(order);
expect(invoice.lines).toHaveLength(3);
expect(invoice.total).toBe('£126.00');
expect(invoice.dueDate).toBe('2026-10-18');
Step 6 — Recognise an equivalent mutant and stop. Some survivors cannot be killed because the change genuinely does not alter behaviour — a defensive clamp that the type system already guarantees, a loop bound that is always reached. Mark them and move on rather than contorting a test around them.
// `i <= n - 1` and `i < n` are equivalent here; no test can distinguish them
// Stryker disable next-line EqualityOperator: equivalent for an inclusive range
for (let i = 0; i < items.length; i++) { /* … */ }
Verification
Verify each fix individually and immediately, because a survivor killed by accident teaches nothing. Re-run the single file after each rewrite and check the mutant you aimed at moved from survived to killed.
npx stryker run --mutate "src/domain/tax.ts"
# Mutation score: 88.89% → 100.00%
# survived: 0
Then verify the rewrite did not just move the problem. A test that now asserts an exact value but computes that value the same way the implementation does is a tautology — it will pass whatever the implementation returns. Read the new assertion and ask whether a reviewer who had never seen the code could tell it is right.
// tautology: recomputes rather than states the expectation
expect(calculateTax(order)).toEqual({ vat: order.net * rate, gross: order.net * (1 + rate) });
// stated expectation: a reviewer can check this by hand
expect(calculateTax({ net: 50, rate: 'standard' })).toEqual({ vat: 10, gross: 60 });
Finally, confirm the score moved for the whole directory and that nothing regressed elsewhere — strengthening one test occasionally reveals that another was relying on the loose behaviour.
Troubleshooting
Symptom: killing one survivor makes two more appear. Diagnosis: the sharper assertion increased per-test coverage, so mutants previously reported as having no coverage are now reachable and survived. Fix: this is progress, not regression — the mutants existed all along and were simply unmeasured. Work them the same way.
Symptom: a survivor sits on a line no test could reasonably reach. Diagnosis: dead code, or a defensive branch that the type system makes unreachable. Fix: delete the branch. A survivor on unreachable code is the tool telling you the code is unnecessary, which is more valuable than any test you could write for it.
Symptom: the same rewrite has to be repeated across dozens of tests. Diagnosis: the suite shares a weak helper — a custom matcher or an assertion utility that everyone calls. Fix: strengthen the helper once rather than every call site, and let the next mutation run confirm the whole family of survivors disappeared together.
FAQ
Should every survivor be killed?
No. Aim at survivors in code where a wrong answer has a real consequence — money, permissions, data integrity, anything a user would notice or an auditor would ask about. Survivors in logging, formatting or presentational code are usually better excluded from the glob than tested, because the test you would write to kill them costs more than the defect it prevents.
How do I tell an equivalent mutant from a genuine gap?
Ask whether any input could distinguish the two versions. If the mutated operator changes behaviour only for a value the type system or an earlier guard makes impossible, it is equivalent. If you can name a concrete input that produces a different result, it is a gap and the input you just named is the test case to write.
Does this apply to component tests as well as pure functions?
The technique applies, but the economics change. Component tests are slower, so a mutation run over them is expensive, and many mutants in presentational code are equivalent or uninteresting. A practical split is to mutate the hooks and helpers that carry logic, and to judge components by the behaviour-focused queries described in Testing Library best practices.
Will strengthening assertions make tests more brittle?
Precise is not the same as brittle. A brittle test asserts on incidental detail — a class name, an internal call order, a whole rendered tree. A precise test asserts on the value the code exists to produce, which changes only when the intended behaviour changes. Mutation testing pushes you toward the second, and often away from the first, because snapshots and call-count assertions are exactly what it exposes as weak.
Related
- Back to Mutation Testing & Assertion Quality
- Running Stryker mutation testing with Vitest — producing the report this guide works from.
- Choosing mutation score targets per module — how good is good enough, per area.
- Why 100% coverage is the wrong target — the metric these assertions were gaming.