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.

How much wrong behaviour each assertion tolerates A not-to-throw assertion accepts nearly every implementation, truthiness accepts most, shape matching accepts any values of the right type, and an exact value assertion accepts only the correct answer. Set of implementations the assertion accepts not.toThrow() almost all toBeTruthy() most toMatchObject({ id: expect.any(String) }) right shape, any value toBe(10.1) only the correct answer a mutant survives whenever the wrong implementation is still inside the accepted set
A survivor is proof that the mutated implementation fell inside what your assertion was willing to accept.

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');
Survivor mutator types mapped to the assertion smell behind them Conditional survivors point at missing boundary cases, arithmetic survivors at truthiness or shape-only assertions, method-call survivors at asserting on mocks, and block-removal survivors at tests that only check nothing threw. Mutator that survived Where to look in the test ConditionalExpression no case at the boundary ArithmeticOperator truthiness or shape only MethodExpression asserts on a mock, not output BlockStatement removed only checks it did not throw
The mutator name is a diagnosis: it tells you which assertion is missing before you open the file.

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.

Triaging a survivor list in priority order Survivors in code paths that carry money, permissions or data loss are addressed first; survivors in presentational or logging code are excluded from the glob; equivalent mutants are marked and skipped. Fix now money, permissions, data loss, correctness sharpen the assertion Exclude logging, copy strings, presentational code narrow the glob instead Mark and skip equivalent mutants no test can kill them comment says why a report worked in this order stays useful; one worked top to bottom becomes a chore
Triage by consequence, not by line number — most of the value sits in a small fraction of the list.

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.