ResourceStrykerJS

Mutation Testing Explained

What mutants, survivors, and mutation score actually mean — and a worked JavaScript example showing how mutation testing catches gaps that coverage misses.

Code coverage tells you which lines of code your tests executed. It cannot tell you whether your tests actually checked anything meaningful about them. A test that calls a function and asserts nothing will still show 100% coverage on every line it touches.

Mutation testing closes that gap by testing your tests. A tool such as Stryker takes your source code and automatically introduces small, deliberate bugs — one at a time — called mutants. It then reruns your existing test suite against each mutant. If a test fails, the mutant is caught. If every test still passes despite the injected bug, your suite failed to notice a real behavioural change.

Test filetest-discount.jsApply mutation operatore.g. > → >=Mutant createdone changed lineRun test suiteagainst the mutantDid the testfail?yesnoKilledSurvived

CLICK ANY STEP FOR AN EXPLANATION

Mutation score vs test coverage?Coverage answers “was this line run?”. Mutation testing answers “would my tests notice if this line were wrong?” — a much stronger guarantee about the quality of the test suite itself.

Each mutant is produced by a mutation operator — a rule for making one small, syntactically valid change to the code: flipping a comparison, swapping an arithmetic operator, negating a boolean, emptying a block. Stryker ships dozens of these operators and applies them one location at a time, so a single mutant always differs from your source by exactly one change.

After the test suite runs against a mutant, it lands in one of a few buckets:

  • Killed — at least one test failed. The suite detected the injected bug.
  • Survived — every test still passed. Nothing in the suite actually verifies that behaviour.
  • No coverage — no test even executed the mutated line, so it couldn't have been killed.
  • Timeout — the mutant caused an infinite loop or hang; Stryker counts this as killed, since the suite did detect a problem.

The mutation score is the percentage of mutants killed out of all the valid ones generated. It is a direct measure of how much a test suite would actually notice if the underlying logic broke.

MUTATION SCORE74%
31 killed6 survived5 no coverage
A rule of thumbTreat the mutation score as a number that shouldn't go down. A drop means new or changed code is being exercised less thoroughly than what came before it — fewer of the behaviours you've added are actually being checked, even if coverage still looks fine.

Common mutation operators, and what surviving one usually tells you:

OPERATOREXAMPLE CHANGEWHAT SURVIVING IT REVEALS
Arithmetic Operatorprice * 0.9
price / 0.9
Calculations are actually checked against an expected value
Equality Operatorprice > 100
price >= 100
Boundary values are covered, not just the middle of a range
Logical OperatorisMember && price > 100
isMember || price > 100
Every branch of a combined condition matters to some test
Conditional Expressionif (isMember) {…}
if (true) {…}
The condition itself — not just one branch — affects the result
Block Statement{ return price * 0.9; }
{ }
A block's contents actually run and are observed
Boolean Literalreturn true;
return false;
Boolean return values are asserted, not ignored

Take a small discount function and a single test for it, using Jest and StrykerJS:

// discount.js
function applyDiscount(price, isMember) {
  if (isMember && price > 100) {
    return price * 0.9;
  }
  return price;
}

module.exports = { applyDiscount };
// discount.test.js
const { applyDiscount } = require('./discount');

test('members spending over 100 get 10% off', () => {
  expect(applyDiscount(200, true)).toBe(180);
});

One test, one green tick, 100% line coverage on discount.js. Running npx stryker run against just this file produces four mutants:

Mutant 1 · LogicalOperatorSurvived
isMember || price > 100

The only test calls applyDiscount(200, true) — isMember is already true, so swapping && for || doesn't change that result. No test exercises isMember === false.

Mutant 2 · EqualityOperatorSurvived
price >= 100

200 > 100 and 200 >= 100 are both true, so the test can't tell them apart. Nothing checks the boundary at exactly 100.

Mutant 3 · ArithmeticOperatorKilled
price / 0.9

200 * 0.9 is 180, but 200 / 0.9 is ≈222.22. The test's expect(...).toBe(180) fails immediately.

Mutant 4 · BlockStatementKilled
{ }

With an empty if-block, applyDiscount(200, true) falls through to return price, giving 200 instead of the expected 180. The test fails.

Mutation score for this file2 killed out of 4 mutants — a 50% mutation score, despite full line coverage. The two survivors point at exactly what's missing: a non-member case, and the exact boundary at 100.

Adding two small tests kills both survivors:

test('non-members are not discounted even over 100', () => {
  expect(applyDiscount(200, false)).toBe(200);
});

test('exactly 100 does not qualify for the discount', () => {
  expect(applyDiscount(100, true)).toBe(100);
});

Re-running Stryker now kills all four mutants — a 100% mutation score — using two extra test cases that a coverage report alone would never have asked for.

Mutation testing is powerful but not free — it typically reruns parts of your suite once per surviving mutant location, so it is far slower than a normal test run. A few habits keep it useful rather than painful:

  • Scope it to what matters. Point mutate at core business logic — pricing, auth, calculations, parsing — rather than the whole codebase. That's where a silent bug is expensive.
  • Get coverage first, mutation score second. Mutation testing amplifies the tests you already have; it doesn't replace writing tests for code nobody exercises yet.
  • Run it where speed doesn't block you. A nightly CI job or a check on pull requests touching critical files suits it better than a pre-commit hook.
  • Use incremental mode. Stryker's --incremental flag re-tests only what changed since the last run, keeping subsequent runs fast.
  • Don't chase 100%. Some survivors are equivalent mutants — semantically identical to the original code, impossible to kill. Mark them with a Stryker ignore comment and move on.
  • Treat every real survivor as a lead. It's usually either a missing test case (as in the example above) or a boundary nobody thought to check.
A high coverage number can hide a suite full of tests that never actually assert anything useful. Mutation testing is the fastest way to find out whether that's happening to you.