Mutation testing met Vitest: Stryker instellen zonder ruis
8m leestijd

Mutation testing met Vitest: Stryker instellen zonder ruis

Een StrykerJS-config voor Vitest, en de drie keuzes die bepalen of de score iets zegt: welke bestanden je muteert, wat je met statische mutanten doet en naar welke score je drempel kijkt. Met de cijfers van de code achter deze site.

bewijs67,98 totale mutation score, waar covered 78,15 zegtstryker 10.1.1, vitest 5.0.53 bronnen13 november 2026
eigen meting
Zes bestanden van deze site, 1.374 mutanten, met en zonder statische mutanten
getest op
stryker 10.1.1, vitest 5.0.5
gecontroleerd
13 november 2026

In augustus draaide ik voor het eerst mutation testing op de code achter deze site. De suite was groen en de coverage zag er prima uit. Een middag later wist ik dat drie trefwoorden in het filter tegen prompt injection nog nooit iets hadden tegengehouden.

Het patroon beperkingen? betekent "beperkinge" met een optionele n. Het enkelvoud "beperking" matchte dus nooit, en een woord dat niet bestaat wel. In richtlijnen? en antwoorden? zat dezelfde fout. Geen test had het gemerkt, want geen test probeerde het enkelvoud.

Een beveiligingsfilter dat geen enkelvoud kent. Groen, uiteraard.

Waarom coverage zoiets niet ziet heb ik eerder beschreven. Dit artikel gaat over de opzet.

StrykerJS aan een Vitest-project hangen kost tien minuten, en daar houden de meeste handleidingen op. Je hebt dan een getal. Wat het telt, heeft niemand je verteld.

Of de score iets zegt, hangt af van drie keuzes: wat je muteert, wat je met statische mutanten doet en naar welke score je drempel kijkt. Bij die laatste zat ik er zelf maanden naast.

Eén mutation run met twee scores: covered telt 930 en 260 mutanten, totaal telt ook de 178 zonder coverage mee.

Stryker instellen met Vitest ​

Twee packages, naast de Vitest die je al hebt:

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

Zit je op Vitest 5, neem dan runner 10.1.1 of nieuwer. Met een oudere runner overleeft elke mutant zonder dat er een test draait.

De config waar ik op uitkwam, met de bestandsnamen vervangen:

js
// stryker.config.mjs
export default {
  testRunner: 'vitest',
  vitest: { configFile: 'vitest.mutation.config.ts', related: false },
  mutate: ['server/services/search.mjs', 'scripts/audit.mjs'],
  reporters: ['clear-text', 'progress', 'json'],
  jsonReporter: { fileName: 'reports/mutation.json' },
  thresholds: { high: 80, low: 65, break: 65 },
  ignorePatterns: ['releases', '.vitepress/cache'],
  tempDirName: 'node_modules/.stryker-tmp',
}

Drie regels daarin staan er omdat het zonder misging.

  • Een eigen Vitest-config. De gemuteerde bestanden zijn pure services. De jsdom-omgeving en de Vue-plugin uit de gewone config zijn dan ballast die je bij elke mutant opnieuw meesleept. De aparte config draait in node en noemt alleen de testbestanden die bij de gemuteerde code horen. Eén test die fs/promises op moduleniveau mockt, ging stuk in de geïnstrumenteerde run en staat er daarom niet in.
  • vitest.related: false. Standaard vraagt de runner Vitest alleen om de tests die bij het gemuteerde bestand horen. Bij mijn .mjs-tests vond hij er geen, en zonder tests valt er niets te meten. De documentatie van de runner raadt aan dat uit te zetten als je tests de broncode niet rechtstreeks importeren. Daarna bepaalt de coverage per test welke tests echt draaien.
  • ignorePatterns. Stryker kopieert je werkmap naar een sandbox. Op een symlink naar een map liep die kopie stuk, en build-output kopiëren kost alleen tijd.

Wat je kunt weglaten ​

In mijn config stond coverageAnalysis: 'perTest', met commentaar erboven dat uitlegde dat de run zonder die regel nooit klaar zou komen. De documentatie denkt daar anders over:

Your coverageAnalysis property is ignored. The vitest runner plugin will always use "perTest" coverage analysis (which yields the best performance anyway).

Het commentaar was dus het enige wat die regel deed. Tweeënhalve maand lang was dat het best gedocumenteerde stuk dode config in de repository.

Hij is weg.

Hetzelfde geldt voor een paar Vitest-opties die Stryker zelf vastzet: één worker per runner, stoppen bij de eerste falende test en coverage-rapportage uit. Parallel draaien regelt Stryker met zijn eigen workers, dus daar hoef je in Vitest niets voor te doen.

Welke bestanden muteer je? ​

Niet alles. Het grootste deel van deze repository is content en presentatie. Een overlevende mutant in een Vue-template: wat ga je daarmee doen, een test schrijven die bewijst dat een div een div is?

In mutate staan zes bestanden. Het zijn de plekken waar een test die slaagt zonder iets te bewijzen echte gevolgen heeft: code die bepaalt of een release door mag, de zoekfunctie waar de assistent op de site zijn antwoorden uit haalt, het filter tegen prompt injection en het script dat de repository aan zijn eigen regels houdt.

De vraag per bestand is simpel. Als hier een > stilletjes een >= wordt, merkt iemand dat dan voordat een gebruiker het merkt? Is het antwoord "de test", dan hoort het bestand in de lijst.

Statische mutanten: een kwart van de mutanten, de helft van de tijd ​

De run van vandaag leverde 1.374 mutanten op. Stryker meldde er meteen iets bij: 311 daarvan, 23%, zijn statisch en kosten naar schatting 70% van de testtijd.

Een statische mutant zit in code die één keer draait, bij het laden van het bestand. Een constante bovenaan een module, een lijst met patronen. Stryker kan zo'n mutant niet aan- en uitzetten terwijl de tests lopen en weet ook niet welke tests hem raken. Dus krijgt zo'n mutant een verse omgeving en de hele suite.

Bij mij zijn het vooral strings en reguliere expressies: 139 en 104 van de 311. Dat zijn de patroonlijsten bovenaan het injectiefilter en de zoekcode.

Met --ignoreStatic sla je ze over. Ik heb beide gemeten op dezelfde code:

RunGedoodOverleefdTotaalCovered
Alles93026067,9878,15
Met ignoreStatic70518066,3279,66

Hoe lang een run duurt, hangt af van wat de machine verder aan het doen is. Daarom heb ik beide direct na elkaar geklokt, twee keer: 1:58 tegen 1:00, daarna 1:41 tegen 0:45. Stryker schatte 70%. Het werd de helft. Ook een schatting mag je nameten.

De helft van de tijd eraf, en de score verschuift nauwelijks. Verleidelijk.

Alleen zaten de drie kapotte trefwoorden uit de inleiding in precies zo'n statische lijst met patronen. Overgeslagen mutanten krijgen de status Ignored en tellen niet mee, dus ook niet als er iets mis is.

En dan de ruis. Dezelfde code, dezelfde tests, vier volledige runs vandaag, vier verschillende scores: van 67,98 tot 69,32 op totaal. In drie van die rapporten wisselden 79 mutanten van status tussen de ene run en de andere, en 65 daarvan waren statisch.

Met ignoreStatic lagen drie runs hooguit 0,2 uit elkaar. Waarom de statische zo wiebelen, weet ik nog niet. Dat het meetinstrument zelf een marge heeft, weet ik nu wel.

Mijn regel: ignoreStatic voor de snelle run tijdens het werk, de volledige run voor de meting die telt, en een drempel met wat ruimte eronder.

Twee scores, één drempel ​

Stryker rekent twee scores uit en zet ze naast elkaar in het rapport:

  • Mutation score: gedood (of in een timeout gelopen) gedeeld door alle geldige mutanten. In het rapport de kolom total.
  • Mutation score based on covered code: gedood gedeeld door de mutanten die door minstens één test worden geraakt. De kolom covered.

Het verschil zit in de mutanten met NoCoverage: code waar geen enkele test langskomt. Vandaag waren dat er 178, naast 930 gedode en 260 overlevende. Dat geeft 78,15 op covered en 67,98 op totaal.

De drempel thresholds.break kijkt naar de mutation score, de totale dus. Zakt die eronder, dan eindigt Stryker met exit code 1.

In augustus zette ik break op 75, keurig "uit de meting in plaats van uit een wens", zoals ik er trots bij schreef. De meting was 80,04. Op covered. Totaal stond die dag op 71,16, dus de drempel stond op rood vanaf het moment dat ik hem instelde.

Wel uit een meting, alleen uit de verkeerde.

Hij staat nu op 65, tegen 67,98 in de laagste run van vandaag. En ik stuur op totaal. Covered is het getal dat je wilt geloven, want het streept precies de code weg die er het slechtst aan toe is. Wat geen test raakt, kan ook geen test bewaken.

Welke overlevenden laat je lopen? ​

Een score onder de 100 is een takenlijst, en niet elke taak is de moeite waard.

  • Regex-mutanten. 69 van de 260 overlevenden vandaag. Stryker verbouwt de binnenkant van een patroon zo dat het bij elke realistische invoer nog steeds matcht. Wie die allemaal najaagt, schrijft strings die alleen bestaan om een gemuteerde regex te laten falen, en test dan het gereedschap. Eén ding doe ik wel: per alternatief in een patroon één testgeval. Zo kwam "beperking" boven water.
  • Equivalente mutanten. Twee controles in een opruimfunctie kunnen niet falen, omdat de stap ervoor de uitkomst al vastlegt. Daar staat een notitie bij in het testbestand. Een scenario verzinnen om een mutant te doden die niets kan breken, is bezigheidstherapie.
  • Code die alleen een proces start. De functie die npm run build aanroept, heeft alleen mutanten zonder coverage. Testen betekent een echte build per mutant, of een nepproces dat niets vastlegt wat ertoe doet.

Een score van 100% op deze bestanden zou betekenen dat de tests voor de mutator zijn geschreven.

Waar draait het? ​

In augustus waren het 631 mutanten en 40 seconden. Vandaag 1.374 mutanten en ergens tussen 1:41 en 3:20, afhankelijk van wat de machine verder doet. Het zijn er vooral meer geworden doordat de zoekcode sindsdien flink is gegroeid. Mutation testing groeit mee met je code, en sneller dan je suite.

Per commit is dat te veel, zeker bij meer dan zes bestanden. Wat werkt: een geplande run, wekelijks of elke nacht, waarvan je het JSON-rapport bewaart, zodat je de vorige score ernaast kunt leggen. Zo'n run hoort bij de controles die ook echt op rood kunnen springen, en de drempel eronder mag van mij alleen strenger worden.

De coverage vond in augustus alles goed. De mutation run vond in een middag drie woorden die het filter niet kende. Het instellen is het makkelijke deel. Het getal komt daarna vanzelf, en wat het meet, heb je zelf gekozen.

(12 van 12)