Stryker and Vitest 5: why every mutant suddenly survives
6m read time

Stryker and Vitest 5: why every mutant suddenly survives

After upgrading to Vitest 5, StrykerJS reported every covered mutant as survived, with a green dry run and no error. How to tell a broken runner from a weak test suite in the report itself, what 10.1 fixed, and why the version to install is 10.1.1.

Same code, same tests, one variable changed. In September, on Vitest 4, the tests in a five-mutant fixture I use for Chaos-MCP killed four of them. On Vitest 5 they killed none.

The dry run was green. The suite passed. Stryker printed a tidy report, exited normally and raised no error at all. The only thing that had changed was the score, and a score that drops is exactly what a mutation tool is supposed to show you when your tests are weak.

That is what makes this one dangerous. A broken runner and a weak test suite produce the same number, and the obvious response to a low mutation score, writing more tests, is the one response that cannot help here.

Killed mutants out of twelve: ten on Vitest 4, none on Vitest 5 with runner 10.0.0, ten with 10.1.1 or the command runner.

What the symptom looks like ​

The pattern after a Vitest 5 upgrade, on @stryker-mutator/vitest-runner 10.0.0 or older:

  • The score collapses, all at once. The reporter of stryker-js#6210 watched one project go from 47.36 to 2.96, with the same 2,637 mutants and the same 373 tests.
  • The run gets much faster. That same project finished in 37 seconds instead of about seven minutes. A mutation run that suddenly takes a fraction of the time is not running your tests.
  • The dry run still succeeds. Stryker's initial test run uses no name filter, so it finds and runs every test as before.
  • Ran 0.00 tests per mutant on average. This line is in the clear-text report, and it is the giveaway most people scroll past.

To make sure I could tell these apart on demand, I rebuilt the case today as a twelve-mutant fixture: one grade() function, three tests, two deliberately weak boundaries. Native runner 10.0.0 on Vitest 4.1.11 scores 83.33%. Same runner on Vitest 5.0.5: 0.00%, all twelve survived.

Read the report, not the score ​

The score cannot tell you which problem you have. The JSON report can. Open reports/mutation/mutation.json and look at two fields on a survived mutant:

  • coveredBy: the tests Stryker measured as reaching this mutant during the dry run.
  • testsCompleted: how many tests actually ran against it.

A real gap in your tests looks like this. Tests reached the mutant, ran, and none of them failed:

text
EqualityOperator  L2  "score > 90"  coveredBy=3  testsCompleted=3

A broken runner looks like this. Tests reach the mutant, and not one of them runs:

text
EqualityOperator  L2  "score > 90"  coveredBy=3  testsCompleted=0

coveredBy filled in and testsCompleted: 0 on a survived mutant is a contradiction. Stryker knows which tests cover the line and still executed none of them. Your assertions never got a chance to fail. In my Vitest 5 run every one of the twelve survivors had exactly that shape.

A one-liner that counts them:

bash
node -e '
const r = require("./reports/mutation/mutation.json");
const ms = Object.values(r.files).flatMap(f => f.mutants);
const broken = ms.filter(m => m.status === "Survived" && (m.coveredBy ?? []).length && m.testsCompleted === 0);
console.log(`${broken.length} of ${ms.length} mutants survived without running a test`);
'

If it prints anything above zero, stop writing tests and look at the runner.

Why it happened ​

Vitest 5 changed what testNamePattern matches. The migration guide spells it out: the pattern is now matched against the full chain of suite and test names joined by ' > '. Stryker's runner selects the tests for each mutant by building exactly such a pattern, and up to 10.0.0 it joined the names with a single space.

So for describe('grade') with it('returns A for 95'), the runner asked for grade returns A for 95, Vitest 5 compared that against grade > returns A for 95, nothing matched, zero tests ran, and the mutant was recorded as survived. Static mutants run without a name filter, which is why they kept behaving normally and why the score was rarely a perfect zero on a real project.

The peer range did not warn anyone. @stryker-mutator/vitest-runner declares vitest: >=2.0.0, and it still does in 10.1.1. A peer range is a claim made by whoever wrote it, before the next major existed.

The fix, and which version to install ​

StrykerJS fixed it in 10.1.0 on 23 October 2026, under "support vitest v5".

What 10.1 did not fix is the failure mode. The release also carries "report mutant collection failures instead of surviving mutants", but that one is about a spec file that throws while Vitest collects it. A name filter that matches nothing is not a collection error. The pull request that would catch this whole class, #6146 "never report a mutant run that executed zero tests as survived", has been open since July.

Until it lands, the next mismatch between runner and Vitest can produce exactly the same quiet score. Counting testsCompleted: 0 stays your job.

One trap: 10.1.0 is not on npm. npm view @stryker-mutator/core@10.1.0 returns a 404. A provenance problem in the release pipeline stopped it, and 10.1.1 is the re-release of the same code an hour later. Pin that one:

bash
npm install --save-dev @stryker-mutator/core@10.1.1 @stryker-mutator/vitest-runner@10.1.1

One known rough edge remains on 10.1.1 with Vitest 5: logLevel: 'debug' crashes on a circular config (#6272, open), so leave debug logging off.

I re-ran the fixture on 10.1.1 with Vitest 5.0.5 and changed nothing else. 83.33%, ten killed, two survived, and the same two boundary mutants as on Vitest 4, each with testsCompleted equal to coveredBy. Stryker's built-in command runner on Vitest 5 gives the same split, slower, because it reruns the suite as a black box for every mutant. That makes it a good second opinion when you doubt the native runner.

If you cannot upgrade yet, the command runner is the safe workaround. Switching coverage analysis does not help. On my fixture coverageAnalysis: 'all' and 'off' both still scored 0.00%, and the issue reporter got the same 2.96 with 'all' and with vitest.related: false.

Runner problem or real gap: the decision ​

  1. Count the contradictions. Survived mutants with coveredBy set and testsCompleted: 0. Above zero: runner problem.
  2. Check the versions. Vitest 5 with vitest-runner 10.0.0 or older is this exact bug. Upgrade to 10.1.1.
  3. Compare against a second runner. Same commit, testRunner: 'command'. If the split moves, the native runner was wrong.
  4. Only then write tests. A survivor whose testsCompleted equals its coveredBy is a real gap: tests ran, none noticed. That one deserves a better assertion.

Step 4 is where the usual mutation testing workflow starts. Steps 1 to 3 make sure you are not sending an agent to write tests against a number that measured nothing. An agent will happily do it, too: it gets a list of survivors, it writes tests, the score does not move, and it writes more.

What I changed in Chaos-MCP ​

Chaos-MCP (tool page) runs Stryker for an agent and hands back survivors as a to-do list, so a false survivor is the worst output it can produce. On 6 September it got a warning for runs that kill nothing at all. On 19 September it started sending Vitest 5 projects to the command runner, because the native runner's peer range promised support that measurement did not confirm.

Today, with the fixture above as evidence, the native runner's version ceiling moved to Vitest 5, but only together with a floor on the runner: Vitest 5 gets the native runner when the installed vitest-runner is 10.1 or newer. Anything older, or a runner it cannot find, still goes the slow way. The version window is measured rather than read off a peer range, the same idea as a quality gate that only moves one way: a gate is only worth as much as the measurement under it.

A mutation score is a measurement of your tests, taken by a tool that can break. Before you act on a score that dropped overnight, check that the tool still ran them.

(10 of 10)