Stryker en Vitest 5: waarom ineens alle mutanten overleven
7m leestijd

Stryker en Vitest 5: waarom ineens alle mutanten overleven

Na de upgrade naar Vitest 5 markeerde StrykerJS elke mutant die je tests raken als overleefd, met een groene dry run en zonder error. Hoe je in het rapport zelf een kapotte runner onderscheidt van een zwakke testsuite, wat 10.1 repareerde en waarom je 10.1.1 installeert.

Dezelfde code, dezelfde tests, één ding anders. In september, op Vitest 4, werden er in een fixture met vijf mutanten die ik voor Chaos-MCP gebruik vier gedood. Op Vitest 5 niet één.

De dry run was groen. De suite slaagde. Stryker leverde een net rapport af, stopte normaal en gaf geen enkele error. Alleen de score was veranderd, en een dalende score is precies wat een mutation-tool je hoort te laten zien als je tests zwak zijn.

Dat maakt deze bug zo verraderlijk. Een kapotte runner en een zwakke testsuite geven hetzelfde getal, en de voor de hand liggende reactie op een lage mutation score, meer tests schrijven, is hier de enige die gegarandeerd niets oplevert.

Gedode mutanten van de twaalf: tien op Vitest 4, nul op Vitest 5 met runner 10.0.0, tien met 10.1.1 of de command runner.

Zo ziet het symptoom eruit ​

Het patroon na een upgrade naar Vitest 5, met @stryker-mutator/vitest-runner 10.0.0 of ouder:

  • De score stort in één keer in. De melder van stryker-js#6210 zag een project van 47,36 naar 2,96 gaan, met dezelfde 2.637 mutanten en dezelfde 373 tests.
  • De run is ineens veel sneller. Datzelfde project was in 37 seconden klaar in plaats van ongeveer zeven minuten. Een mutation-run die opeens een fractie van de tijd kost, draait je tests niet.
  • De dry run slaagt gewoon. De eerste testrun van Stryker gebruikt geen naamfilter, dus die vindt en draait alle tests zoals altijd.
  • Ran 0.00 tests per mutant on average. Die regel staat in het tekstrapport en verraadt alles, maar bijna iedereen leest eroverheen.

Om zeker te weten dat ik de twee altijd uit elkaar kan houden, heb ik het vandaag nagebouwd als fixture met twaalf mutanten: één functie grade(), drie tests en twee bewust zwakke grenswaarden. De native runner 10.0.0 op Vitest 4.1.11 haalt 83,33%. Dezelfde runner op Vitest 5.0.5: 0,00%, alle twaalf overleefd.

Lees het rapport, niet de score ​

Welk probleem je hebt, staat in het JSON-rapport. Open reports/mutation/mutation.json en kijk bij een overlevende mutant naar twee velden:

  • coveredBy: de tests die deze mutant volgens de dry run raken.
  • testsCompleted: hoeveel tests er echt tegen deze mutant zijn gedraaid.

Een echt gat in je tests ziet er zo uit. De tests raakten de mutant, draaiden, en geen enkele faalde:

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

Een kapotte runner ziet er zo uit. De tests raken de mutant, maar er draait er niet één:

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

Een gevulde coveredBy met testsCompleted: 0 bij een overlevende mutant spreekt zichzelf tegen. Stryker weet welke tests de regel raken en heeft er toch geen enkele uitgevoerd. Je assertions hebben niet eens de kans gekregen om te falen. In mijn run op Vitest 5 hadden alle twaalf overlevenden precies die vorm.

Met deze one-liner tel je ze:

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`);
'

Print hij meer dan nul, stop dan met tests schrijven en kijk naar de runner.

Waarom het gebeurde ​

Vitest 5 veranderde waar testNamePattern op matcht. De migratiegids zegt het letterlijk: het patroon wordt nu vergeleken met de volledige keten van suite- en testnamen, aan elkaar gezet met ' > '. De runner van Stryker kiest de tests per mutant door precies zo'n patroon te bouwen, en tot en met 10.0.0 plakte hij de namen aan elkaar met één spatie.

Bij describe('grade') met it('returns A for 95') vroeg de runner dus om grade returns A for 95. Vitest 5 legde dat naast grade > returns A for 95, vond geen match, draaide nul tests, en de mutant ging als overleefd de boeken in. Statische mutanten draaien zonder naamfilter en bleven zich dus normaal gedragen. Daarom kwam de score bij een echt project zelden precies op nul uit.

De peer range waarschuwde niemand. @stryker-mutator/vitest-runner zegt vitest: >=2.0.0, en in 10.1.1 staat dat er nog steeds. Een peer range is een belofte van degene die hem schreef, op een moment dat de volgende major nog niet bestond.

De fix, en welke versie je installeert ​

StrykerJS repareerde het op 23 oktober 2026 in 10.1.0, onder "support vitest v5".

Het stille falen zelf repareerde 10.1 niet. De release bevat ook "report mutant collection failures instead of surviving mutants", maar die gaat over een spec-bestand dat een error gooit terwijl Vitest het inleest. Een naamfilter dat niets matcht, is geen collection error. De pull request die deze hele categorie zou vangen, #6146 "never report a mutant run that executed zero tests as survived", staat al sinds juli open.

Tot die erin zit, kan de volgende mismatch tussen runner en Vitest precies dezelfde stille score opleveren. testsCompleted: 0 tellen blijft dus jouw werk.

Eén valkuil: 10.1.0 staat niet op npm. npm view @stryker-mutator/core@10.1.0 geeft een 404. Een provenance-probleem in de release-pipeline hield hem tegen, en een uur later kwam dezelfde code opnieuw uit als 10.1.1. Pin die:

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

Met 10.1.1 op Vitest 5 is er nog één bekend probleem: logLevel: 'debug' crasht op een circulaire config (#6272, nog open), dus laat debug-logging uit.

Ik heb de fixture opnieuw gedraaid op 10.1.1 met Vitest 5.0.5, verder niets veranderd. 83,33%, tien gedood, twee overleefd, en dat zijn dezelfde twee grenswaardemutanten als op Vitest 4, elk met testsCompleted gelijk aan coveredBy. De ingebouwde command runner van Stryker geeft op Vitest 5 dezelfde verdeling, alleen trager, omdat hij de suite per mutant als black box opnieuw draait. Twijfel je aan de native runner, dan heb je daarmee een goede second opinion.

Kun je nog niet upgraden, dan is de command runner de veilige workaround. Een andere coverage-analyse helpt niet. Op mijn fixture scoorden coverageAnalysis: 'all' en 'off' allebei nog steeds 0,00%, en de melder van het issue kwam met 'all' en met vitest.related: false op dezelfde 2,96 uit.

Runner-probleem of echt gat: zo beslis je ​

  1. Tel de tegenstrijdigheden. Overlevende mutanten met een gevulde coveredBy en testsCompleted: 0. Meer dan nul: runner-probleem.
  2. Controleer de versies. Vitest 5 met vitest-runner 10.0.0 of ouder is precies deze bug. Upgrade naar 10.1.1.
  3. Vergelijk met een tweede runner. Zelfde commit, testRunner: 'command'. Verschuift de verdeling, dan zat de native runner ernaast.
  4. Schrijf pas dan tests. Een overlevende mutant met testsCompleted gelijk aan coveredBy is een echt gat: de tests draaiden en geen enkele faalde. Die verdient een betere assertion.

Bij stap 4 begint de gewone werkwijze met mutation testing. Stap 1 tot en met 3 voorkomen dat je een agent tests laat schrijven tegen een getal dat niets heeft gemeten. Een agent doet dat namelijk zonder morren: hij krijgt een lijst overlevenden, schrijft tests, ziet de score niet bewegen en schrijft er nog meer.

Wat ik in Chaos-MCP heb aangepast ​

Chaos-MCP (toolpagina) draait Stryker voor een agent en geeft de overlevenden terug als takenlijst, dus een valse overlevende is het slechtste wat het kan opleveren. Op 6 september kreeg het een waarschuwing voor runs die helemaal niets doden. Op 19 september ging het Vitest 5-projecten naar de command runner sturen, omdat de peer range van de native runner ondersteuning beloofde die een meting niet bevestigde.

Vandaag, met de fixture hierboven als bewijs, schoof de bovengrens op naar Vitest 5, maar alleen samen met een ondergrens voor de runner: Vitest 5 krijgt de native runner als de geïnstalleerde vitest-runner 10.1 of nieuwer is. Een oudere runner, of een runner die Chaos-MCP niet kan vinden, gaat nog steeds via de trage command runner. Het versiebereik komt nu uit een meting, hetzelfde idee als een kwaliteitsdrempel die alleen strenger wordt: een drempel is maar zoveel waard als de meting eronder.

Een mutation score meet je tests, met een tool die zelf stuk kan gaan. Daalt je score van de ene op de andere dag, kijk dan eerst of de tool je tests nog wel heeft gedraaid.

(10 van 10)