Mutation Testing for JavaScript with Stryker Mutator
AI generated
JS
() =>
JavaScript · Testing Advanced · Stryker Mutator · Test Quality
Mutation Testing for JavaScript
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.

18 min read Stryker Mutator · Mutation Score · Vitest Node.js 20+ · TypeScript optional

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.

11. FAQ: Mutation Testing for JavaScript

1What is mutation testing?
Injecting artificial bugs into code and checking whether the test suite detects them.
2Difference to code coverage?
Coverage measures execution, mutation testing measures actual bug detection by the tests.
3Killed vs. survived?
Killed: test caught the bug. Survived: tests stayed green, test gap present.
4Why not aim for 100 percent?
Equivalent mutants can never be killed regardless of test quality, 70 to 90 percent is realistic.
5How long does a run take?
Much longer than normal tests, but reducible via perTest coverage and incremental mode.
6Run on every commit?
Usually not, better on pull requests or as a nightly job with incremental mode.
7Fixing a surviving mutant?
Add a missing test or replace a vague assertion with an exact value comparison.
8Which test runners are supported?
Vitest, Jest, Mocha, Karma and others via dedicated Stryker runner plugins.
9What is an equivalent mutant?
A mutant with no observable behavior difference from the original, unkillable in principle.
10Worth it for the whole project?
Usually not, better focused on critical business logic instead of generated or trivial files.