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.
CLICK ANY STEP FOR AN EXPLANATION
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:
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.
Common mutation operators, and what surviving one usually tells you:
| OPERATOR | EXAMPLE CHANGE | WHAT SURVIVING IT REVEALS |
|---|---|---|
| Arithmetic Operator | price * 0.9 price / 0.9 | Calculations are actually checked against an expected value |
| Equality Operator | price > 100 price >= 100 | Boundary values are covered, not just the middle of a range |
| Logical Operator | isMember && price > 100 isMember || price > 100 | Every branch of a combined condition matters to some test |
| Conditional Expression | if (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 Literal | return 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:
The only test calls applyDiscount(200, true) — isMember is already true, so swapping && for || doesn't change that result. No test exercises isMember === false.
200 > 100 and 200 >= 100 are both true, so the test can't tell them apart. Nothing checks the boundary at exactly 100.
200 * 0.9 is 180, but 200 / 0.9 is ≈222.22. The test's expect(...).toBe(180) fails immediately.
With an empty if-block, applyDiscount(200, true) falls through to return price, giving 200 instead of the expected 180. The test fails.
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:
mutate at core business logic — pricing, auth, calculations, parsing — rather than the whole codebase. That's where a silent bug is expensive.--incremental flag re-tests only what changed since the last run, keeping subsequent runs fast.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.