Home

Mutating code with StrykerJS

July 20, 2026

I was expanding test coverage on a library of my own at A-SAFE DIGITAL, the company I currently work at. Applying TDD, function by function, pushing the coverage percentage a bit higher every day.

Until I asked myself a question I'd already asked before, when I wrote about why my tests weren't giving me confidence: does high coverage mean good tests?

This time I didn't settle for the suspicion. I went straight to StrykerJS, the mutation testing framework for JavaScript and TypeScript, and used it for real on my own code.

What it does, in one sentence

StrykerJS takes your source code and generates "mutants": small automatic variations like changing a > to a >=, an && to an ||, or emptying out an if block.

Then it runs your test suite against each mutant. If a test fails, the mutant died: your tests did their job. If every test stays green, the mutant survived: there's a branch of your logic that nobody is actually checking.

Coverage tells you which lines got executed. Stryker tells you whether those lines actually mattered.

Installing it and getting started

sh
1npm init stryker@latest

The command asks which test runner you use (Jest, Mocha, Vitest, Karma...) and generates a matching stryker.conf.js. In my case, on a package using Jest and TypeScript, it ended up looking like this:

javascript
1// stryker.conf.js
2module.exports = {
3 packageManager: 'npm',
4 testRunner: 'jest',
5 checkers: ['typescript'],
6 tsconfigFile: 'tsconfig.json',
7 mutate: ['src/**/*.ts', '!src/**/*.spec.ts'],
8 reporters: ['html', 'clear-text', 'progress'],
9 thresholds: { high: 80, low: 60, break: 50 },
10}

And running it is as simple as:

sh
1npx stryker run

Reading the report, the part that matters

The first run on my library gave me this:

Mutation testing [====================] 100% (elapsed: 1m, remaining: 0s) Kill: 142 Survived: 19 Timeout: 2 No Coverage: 4 File | % score | # killed | # survived src/validators.ts | 71.42 | 15 | 6 src/parser.ts | 88.00 | 22 | 3

I had coverage above 90% on validators.ts. The mutation score said 71.42%. That gap is exactly the point: there were lines executed by my tests that weren't being verified by any real assertion.

One of the surviving mutants, for example, changed this:

typescript
1// original
2if (value === null || value === undefined) return false
3
4// surviving mutant
5if (value === null && value === undefined) return false

My tests stayed green because I never explicitly tested the undefined case, only null. The mutant pointed straight at the gap.

Mutants aren't just operators

What I didn't expect was the variety of mutations it applies: it's not just + for - or === for !==. It also mutates string literals ("hello"""), replaces array methods (filter() with identity, some() with every()), and can empty out entire code blocks to see if anything notices they're gone.

When a mutant like that survives, it almost always means the same thing: that branch of code has no test whose outcome depends on it. It exists, but nobody's watching it.

What I changed after this

I stopped treating "coverage" as the goal and started using the mutation score as the number that actually matters to me. A few rules I picked up:

  1. I run Stryker in incremental mode so I'm not waiting minutes on every local iteration.
  2. I exclude from mutate whatever isn't worth testing: types, generated code, mocks.
  3. When a mutant survives, I don't write just any test. I write the test that would specifically kill it.
  4. I don't chase 100%. It's career-limiting — diminishing returns kick in long before that.

Conclusion

Coverage answers "did this line run?". Mutation testing answers "would anyone care if this line broke?". They're different questions, and the second one is the one that actually protects production.

After this library, it stayed in the workflow: Stryker runs on every PR, in incremental mode, and the build fails if the score drops below the threshold I set.


Project repo: stryker-mutator/stryker-js