use Stryker to check whether your tests actually test anything
One hundred percent code coverage only means every line was executed once, not that a test would actually notice a real bug. Mutation testing flips that question around: Stryker deliberately turns individual lines of code into small bugs and checks whether your tests actually catch these mutants and fail, instead of silently staying green.
Table of contents
- 1. Why code coverage is not enough
- 2. The concept: mutants, killed and survived
- 3. Installing Stryker and running the first pass
- 4. Understanding mutators: what changes Stryker makes
- 5. Interpreting the mutation score correctly
- 6. Fixing surviving mutants deliberately
- 7. Performance: runtime and incremental mode
- 8. Anchoring mutation testing in the CI pipeline
- 9. Mutation testing compared to code coverage
- 10. Summary
- 11. FAQ
1. Why code coverage is not enough
Code coverage tools measure what proportion of the code was executed during a test run. One hundred percent coverage only means every line ran at least once, but says nothing about whether the tests actually checked the correct behavior while doing so. A test that calls a function but never checks the result with expect() produces full coverage and yet zero explanatory power. This exact gap is closed by mutation testing.
Mutation testing for JavaScript asks a different question: would the test suite even notice if the code were broken? To answer that, Stryker Mutator deliberately changes individual lines of code, a > becomes >=, a true becomes false, a + becomes -, and reruns the entire test suite against each of these small, artificially introduced bugs, the so-called mutants. If at least one test fails, the mutant was killed, the test suite detected the bug. If all tests stay green, the mutant survived, and that is exactly the alarm signal.
The practical value of mutation testing shows especially in projects that are proud of high coverage numbers yet still regularly experience bugs in production. In such cases, Stryker frequently reveals that many tests execute code but contain no or only weak assertions, delivering a considerably more honest metric for a test suite's actual effectiveness than raw coverage percentages.
2. The concept: mutants, killed and survived
A mutant is a minimally altered version of the source code, produced by a so-called mutator, a rule that systematically changes a specific code expression. Stryker generates a separate mutant for every possible mutation at every relevant place in the code and runs the complete test suite for each one. For a function with a comparison operator, this produces at least one mutant that replaces > with >=, a second that replaces it with <, and so on.
For every mutant in mutation testing, there are exactly two possible outcomes. Killed means at least one test failed while the mutant was active, so the test suite successfully detected the injected bug. Survived means all tests stayed green despite the bug, meaning no test actually checks that specific aspect of the behavior. A third, rarer case is No Coverage, where the mutated code was not reached by any test at all, which points to a coverage gap rather than a missing assertion.
3. Installing Stryker and running the first pass
Installation happens through the official Stryker initializer, which suggests a setup matching the existing project dependencies, including test runner detection for Vitest, Jest or Mocha. After configuration, npx stryker run executes the complete mutation testing pass and produces an HTML report at the end with color-coded mutants directly in the source code.
// npm install --save-dev @stryker-mutator/core @stryker-mutator/vitest-runner
// stryker.config.mjs
export default {
packageManager: 'npm',
reporters: ['html', 'clear-text', 'progress'],
testRunner: 'vitest',
coverageAnalysis: 'perTest',
mutate: [
'src/**/*.js',
'!src/**/*.test.js',
],
thresholds: {
high: 80,
low: 60,
break: 50, // fail the run if the mutation score drops below 50%
},
};
// Run: npx stryker run
// Output includes a summary like:
// 142 mutants tested, 118 killed, 19 survived, 5 no coverage
// Mutation score: 83.10%
Important on the first mutation testing run: do not mutate the entire project at once, especially with larger codebases. The mutate option in the configuration lets you deliberately include specific directories, for instance critical business logic, while generated files, configuration files or purely declarative types stay excluded, since they would not produce meaningful test cases anyway.
4. Understanding mutators: what changes Stryker makes
Stryker ships an entire library of built-in mutators that produce different categories of code changes. Arithmetic operator mutators replace + with - or * with /. Conditional boundary mutators change < to <= and vice versa, one of the most common sources of real off-by-one bugs. Boolean literal mutators swap true for false. And string literal mutators replace a constant string with an empty one, to check whether anyone actually verifies the concrete text content at all.
This range of mutators is the reason mutation testing systematically finds weaknesses a human reviewer would barely catch. A developer reading code immediately sees that if (age >= 18) looks correct. Whether a test actually checks the boundary case age === 18, however, only shows once Stryker mutates exactly that boundary value and observes whether a test fails.
// src/discount.js — original implementation
export function calculateDiscount(age, isMember) {
if (age >= 65 && isMember) {
return 0.2; // 20% senior member discount
}
if (age >= 18) {
return 0.05; // 5% adult discount
}
return 0;
}
// A weak test suite with high coverage but a low mutation score
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './discount.js';
describe('calculateDiscount (weak suite)', () => {
it('returns a discount for an adult', () => {
expect(calculateDiscount(30, false)).toBeGreaterThan(0); // too loose!
});
});
// This test passes 100% coverage but would NOT kill a mutant that
// changes 0.05 to 0.5, or >= to >, because it never asserts the
// exact expected value. Stryker reports these mutants as "survived".
// A strong test suite that actually kills the relevant mutants
describe('calculateDiscount (strong suite)', () => {
it('gives no discount below 18', () => {
expect(calculateDiscount(17, false)).toBe(0);
});
it('gives exactly 5% at the boundary age of 18', () => {
expect(calculateDiscount(18, false)).toBe(0.05);
});
it('gives exactly 20% for senior members at 65', () => {
expect(calculateDiscount(65, true)).toBe(0.2);
});
});
5. Interpreting the mutation score correctly
The mutation score is the percentage of killed mutants relative to the total number of generated mutants, excluding cases marked No Coverage. A score of eighty percent means that four out of five artificially injected bugs actually triggered a test failure. Unlike code coverage, mutation testing has no universally valid target of one hundred percent, because some mutants are semantically equivalent to the original and can never be killed, no matter how good the tests are.
Such an equivalent mutant arises, for instance, when a mutation changes a dead code fragment that never executes anyway, or when two mathematically different but, within the concrete value range, identical expressions result. Stryker cannot automatically detect equivalent mutants, which is why a realistic target for mutation testing usually sits between seventy and ninety percent, depending on the complexity and criticality of the given code, not at the full one hundred percent.
6. Fixing surviving mutants deliberately
Stryker's HTML report marks every surviving mutant directly in the source code with the exact line and type of mutation. The first step when analyzing a survivor is always to ask whether a test exists at all that exercises that code path. If no test exists, the gap is obvious, a new test case with a precise assertion needs to be added.
It gets more complex when a test exists, exercises the code path, but still lacks a sufficiently precise assertion, as in the example with toBeGreaterThan(0) instead of an exact value. Here mutation testing shows its real strength: it makes visible that a test exists and even executes the code, but is worded too weakly to detect an actual regression bug. The fix usually consists of replacing the vague assertion with an exact one that concretely checks the expected value instead of merely confirming a rough direction.
7. Performance: runtime and incremental mode
The obvious downside of mutation testing is runtime. For every single mutant, the relevant subset of the test suite must be run again, which with hundreds of mutants quickly leads to runtimes of several minutes to hours, considerably longer than a single normal test run. Stryker limits this cost through coverageAnalysis: 'perTest', which for each mutant only runs the tests that actually cover the affected code, instead of running the whole suite against every single mutant.
For large projects, Stryker additionally offers incremental mode, which on every run only re-evaluates mutants in changed code and reuses results for unchanged files from a previous run. Combined with parallel execution across multiple CPU cores, this brings the runtime of mutation testing down to a level that can be regularly scheduled even in medium-sized CI pipelines, instead of being limited to occasional, manually triggered analyses.
# Incremental mode: only re-evaluate mutants in changed files
npx stryker run --incremental
# Concurrency: use available CPU cores for parallel test runs
npx stryker run --concurrency 4
# Combine both for fast, regular CI runs on pull requests
npx stryker run --incremental --concurrency 4
8. Anchoring mutation testing in the CI pipeline
Stryker's thresholds configuration lets you deliberately fail the CI run when the mutation score drops below a defined threshold. The break threshold is the hard boundary below which the build is marked as failed, while high and low only serve for color-coding in the HTML report. For mutation testing in the CI pipeline, a moderate starting value is recommended, gradually raised once the existing test suite has been improved accordingly.
Because of runtime, in many projects it makes more sense not to run mutation testing on every commit, but deliberately on pull requests against the main branch or as a nightly job, instead of blocking every single change with the full mutation run. Combined with incremental mode, the trade-off between feedback speed and test depth can be individually tuned for the given project.
9. Mutation testing compared to code coverage
The table below contrasts mutation testing with classic code coverage measurement.
| Criterion | Code coverage | Mutation testing (Stryker) |
|---|---|---|
| Measures | Lines of code executed | Bugs actually detected |
| Detects weak assertions | No | Yes, deliberately |
| Runtime | Seconds to minutes | Minutes to hours, without optimization |
| Target value | Often aiming near 100% | 70 to 90%, due to equivalent mutants |
| Reliability against false confidence | Low, easily fakes safety | High, exposes exactly that deception |
Code coverage and mutation testing do not exclude each other, they complement each other: coverage is a fast, rough prerequisite, while mutation testing answers the actual question of whether the existing tests would truly trigger when it matters.
Mironsoft
Test quality over coverage numbers for Magento and Hyvä projects
Do you know whether your tests would catch real bugs?
We introduce mutation testing with Stryker into your critical business logic, analyze surviving mutants together with your team, and establish realistic mutation score targets in the CI pipeline.
Stryker rollout
Configuration, mutator selection and a first run for critical modules
Survivor analysis
Fix surviving mutants together and sharpen assertions deliberately
CI integration
Incremental mode and thresholds for performant, regular runs
10. Summary
Mutation testing for JavaScript answers a question code coverage cannot answer: would the test suite actually notice a real bug? Stryker Mutator systematically generates small, artificial bugs in the code for this, so-called mutants, and runs the test suite against every single one. Killed mutants confirm effective tests, surviving mutants expose weak or missing assertions that pure coverage measurement would never reveal.
A realistic mutation score usually sits between seventy and ninety percent, because equivalent mutants are fundamentally unkillable. Performance options such as incremental mode and parallel execution make mutation testing practical even for larger codebases, while thresholds in the CI pipeline prevent test quality from gradually eroding. Anyone who applies mutation testing deliberately to critical business logic gains a considerably more honest assessment of actual test coverage than any raw percentage.
Mutation testing for JavaScript with Stryker — the essentials at a glance
Core idea
Inject artificial mutants into the code and check whether tests actually detect them as bugs.
Killed vs. survived
Killed confirms effective tests, survived exposes weak or missing assertions.
Realistic target
70 to 90 percent mutation score, not 100 percent, because of equivalent mutants.
Performance
coverageAnalysis: perTest, incremental mode and parallelization for practical runtimes.