Mutation testing for JavaScript with Stryker: catching bugs your coverage misses
Test coverage is a useful map, but it answers a narrower question than most people assume. Coverage tells you which lines of code ran while your tests executed. It does not tell you whether those tests would actually fail if the code were wrong. You can have 90 percent coverage and a suite that would happily pass even after you break a core function. Mutation testing is how you find that out.
What coverage does and does not prove
Imagine a function that returns a + b, and a test that calls it and never asserts anything about the result. That line is covered. The coverage number goes up. But if someone changes the function to a - b, the test still passes, because it never checked the answer. Coverage counted the line as tested when it was only executed.
This gap is the single most common way a high coverage number gives false comfort. Lines run, assertions are weak or missing, and the suite protects almost nothing. The number looks healthy right up until a regression ships.
How mutation testing works
Mutation testing flips the question around. Instead of asking "did my tests run this line," it asks "would my tests notice if this line were wrong." A mutation testing tool makes small, deliberate changes to your code, one at a time. It might turn a + into a -, a > into a >=, a true into a false, or remove a function call. Each altered version is called a mutant.
For each mutant, the tool runs your test suite. If a test fails, the mutant is "killed," which means your tests caught the injected bug. If every test still passes, the mutant "survived," which means a real bug of that shape would slip through unnoticed. The percentage of mutants your suite kills is the mutation score, and it is a far more honest measure of test quality than coverage alone.
Stryker, the JavaScript and TypeScript tool
In the JavaScript and TypeScript world, the mature mutation testing tool is Stryker. It supports the runners most projects already use, including Vitest and Jest, and it understands TypeScript. Getting started is a matter of adding it as a dev dependency and pointing it at your source and test setup.
A minimal stryker.config.json for a Vitest project looks like this:
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "vitest",
"coverageAnalysis": "perTest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"]
}
Then run it:
npx stryker run
Stryker will instrument your source, generate mutants, run the suite against each one, and report a mutation score along with a list of surviving mutants. Those survivors are the actionable part: each one points at a line where your tests run the code but do not check its behavior. That is where to add or strengthen an assertion.
Practical notes
Mutation testing is heavier than a normal test run, because it runs your suite many times. A few habits keep it manageable:
- Scope it. Point Stryker at the module you care about most rather than the whole repo at once.
coverageAnalysis: "perTest"also lets Stryker skip tests that cannot possibly kill a given mutant, which speeds things up a lot. - Read the survivors, not just the score. A single surviving mutant in critical logic matters more than a high headline percentage.
- Treat it as advisory. Mutation score is a guide for where to invest in better assertions, not a gate that should block every merge on day one.
How we use it
At TaskBounty, mutation testing is part of how we prove a test suite is worth trusting, not just padded to hit a coverage target. When we raise a repo's coverage, we run the new tests through an anti-slop gate that rejects no-assertion tests, self-satisfying assertions, and flaky tests, and then we run Stryker to confirm the tests actually kill injected bugs. Coverage is the signal that the code is exercised; mutation testing is the proof that the tests would catch a regression. This runs on Vitest and Jest projects today, which is where the JavaScript and TypeScript ecosystem lives.
If you want to see where your own repo stands, start with the free coverage check. It shows your current coverage and where the untested, risky code is, which is the natural place to start before layering mutation testing on top.