AI genereert je tests. Maar test het ook echt?
9m leestijd

AI genereert je tests. Maar test het ook echt?

AI-tests halen in seconden een hoge coverage, maar coverage bewijst alleen dat een regel is uitgevoerd. Mutation testing bewijst dat de tests een bug ook echt vangen, en Chaos-MCP zet die loop in je agent.

Geef Claude of ChatGPT een functie en vraag om tests. Een paar seconden later heb je een keurige suite, overal groen, met coverage-cijfers waar elke auditor blij van wordt.

Het voelt als gratis productiviteit.

Er zit een addertje onder het gras.

Coverage meet het verkeerde

Code coverage vertelt je dat een regel is uitgevoerd, niet dat een test zou merken dat die regel iets fout doet.

Dat zijn twee verschillende dingen, en AI is opvallend goed in het eerste en opvallend slecht in het tweede. Modellen herkennen patronen. De papegaai schrijft tests die de implementatie voor zijn neus regel voor regel nabootsen.

De tests bevestigen dat de code doet wat de code al doet. Of dat ook het juiste is, vragen ze nooit.

Een groene suite die niets bewijst

Dit is een functie. Vier regels, niets bijzonders.

js
function shippingCost(weight) {
  if (weight > 10) {
    return 15
  }
  return 5
}

Vraag een AI om tests en je krijgt zoiets:

js
test('zware pakketten kosten meer', () => {
  expect(shippingCost(20)).toBe(15)
  expect(shippingCost(2)).toBe(5)
})

Draai coverage. 100%. Beide takken uitgevoerd, elke regel geraakt, het rapport is één muur van groen.

Verander nu één teken in de code: > wordt >=. Bij een gewicht van precies 10 geeft het origineel 5 terug en de mutant 15. Dat is een echte bug, de klassieke off-by-one op een grenswaarde.

Draai de tests opnieuw. Ze slagen nog steeds.

Niets in de suite roept ooit shippingCost(10) aan. De twee tests kozen 20 en 2, ruim bij de rand vandaan, dus de tak is "gedekt" terwijl de grens die de tak moet bewaken nooit wordt gecontroleerd. Precies de plek waar een ontwikkelaar het snelst de fout in gaat, is precies de plek waar de tests zwijgen.

Dat is geen gekunsteld voorbeeld. Het is verreweg de meest voorkomende vorm van een AI-test: beide takken uitvoeren met waardes uit het veilige midden, 100% coverage halen, en nooit een grenswaarde aanraken.

Erger nog: sommige gegenereerde tests controleren bijna niets. Roep de functie aan, kijk of het resultaat bestaat, klaar. Coverage telt die regel als gedekt. Er is helemaal niets geverifieerd.

Waarom AI de belangrijke bugs mist

AI heeft geen intuïtie. Het weet niet welke fout een ontwikkelaar waarschijnlijk maakt. Het kent je businessregels niet, tenzij je ze uitschrijft. En het verzint zelden uit zichzelf een afwijkend scenario.

De klassiekers:

  • een > waar >= had moeten staan
  • een omgedraaide boolean
  • een vergeten null-check
  • een ontbrekende validatie
  • een edge case rond lege collecties of grenswaarden
  • een uitzondering die niemand test

De gegenereerde tests zien er prima uit. Ze lopen vooral over het happy path.

Dit is het probleem uit je vindt de bug niet als je de code niet schreef, nu met een groen vinkje erbovenop. Het gevoel dat zegt "test de waarde precies op de grens" komt voort uit het feit dat je je ooit aan die grens hebt gebrand. Het model heeft zich nooit gebrand. Het heeft een miljoen tests gelezen en reproduceert hun gemiddelde vorm, en de gemiddelde test tast de rand niet af.

Dezelfde blinde vlek, twee keer

Dit maakt het erger.

De AI schrijft de productiecode. Daarna schrijft dezelfde AI de tests.

Allebei komen ze uit hetzelfde redeneerpatroon. Allebei maken ze dezelfde aannames. Heeft het model de eis verkeerd gelezen en > gebruikt terwijl de regel "10 en hoger" was, dan schrijft het de productiecode met > en vervolgens tests die >-gedrag verwachten. De bug en de test die hem zou moeten vangen komen uit dezelfde foute aanname.

Je houdt een groene suite over die een bug bewaakt in plaats van hem vangt.

Zo ontstaat een lava-laag met een geslaagde CI-badge erbovenop. Niemand kijkt terug naar code die groen en gedekt is. De verkeerde aanname stolt, en de tests zijn het beton dat eroverheen wordt gegoten.

Mutation testing stelt een betere vraag

Mutation testing draait het om. In plaats van je code te testen, verandert het je code, met opzet, in kleine stukjes, en kijkt of je tests alarm slaan.

Zo'n tool maakt aanpassingen als:

  • > wordt >=
  • == wordt !=
  • && wordt ||
  • + wordt -
  • true wordt false
  • een returnwaarde wordt verwisseld

Elke aanpassing is een mutant: een kopie van je code met één opzettelijke fout. Na elke mutant draait de hele suite opnieuw.

Faalt er een test, dan is de mutant gedood. Mooi. Je tests merkten de fout op.

Faalt er geen test, dan heeft de mutant het overleefd. Dat betekent dat je tests die fout niet hadden gevangen als een ontwikkelaar hem per ongeluk had gemaakt. Een overlevende mutant is een bug waar je suite blind voor is, aangetoond in code die je kunt lezen.

Draai het op het shippingCost-voorbeeld en de >-naar->=-mutant overleeft en wijst recht naar regel 2. De tool vertelt je precies dat je grenswaarde ongetest is.

Wat een mutation score je oplevert

Dood 11 van de 12 mutanten en je mutation score is 92%. Dat ene getal draagt meer informatie dan welk coverage-percentage dan ook.

Waar coverage zegt "deze code is uitgevoerd", zegt mutation testing "deze tests zijn sterk genoeg om een fout te vangen". Die tweede zin is degene waar het je om gaat.

100% coverage met een slechte mutation score kan prima, en komt vaak voor. Andersom bijna nooit: om mutanten te doden moet je assertions schrijven die gedrag vastpinnen, en die assertions trekken de coverage als bijvangst omhoog. Jaag op de mutation score en de coverage komt er gratis bij. Jaag op coverage en je krijgt geen van beide.

Eén eerlijke kanttekening, want het getal is geen wondermiddel. Sommige mutanten zijn equivalent: de wijziging levert code op die zich voor elke mogelijke input identiek gedraagt, dus geen enkele test kan hem ooit doden. Dat zijn geen gaten, en een goede workflow laat je ze als zodanig markeren zodat ze de score niet langer omlaag trekken. Een mutation score onder de 100% is een to-dolijst, geen oordeel, en een deel van het werk is beslissen welke survivors echt zijn.

De tools bestaan al

Mutation testing is niet nieuw, en je hoeft het muteren niet zelf te bouwen. Elke grote taal heeft een volwassen engine.

StrykerJS is die voor TypeScript en JavaScript, en het is de moeite waard om te snappen hoe die werkt, want de rest volgt hetzelfde patroon. Stryker parseert je code tot een abstract syntax tree, loopt die boom af en genereert een mutant voor elke plek waar een mutator van toepassing is. De mutators dekken rekenkunde (+ naar -), gelijkheid (== naar !=), grensoperatoren (> naar >=), logische operatoren (&& naar ||), booleans, string-literals, array-declaraties, optional chaining en meer. Voor elke mutant draait het je bestaande test runner, vitest, jest, mocha, wat je al gebruikt, en noteert of er een test faalde.

Het slimme zit in hoe het betaalbaar blijft. Je hele suite één keer per mutant draaien zou op een groot project moordend zijn, dus Stryker verzamelt eerst coverage-data en draait daarna per mutant alleen de tests die de betreffende regel daadwerkelijk raken. Een mutant in een functie die niemand test wordt gemeld als "no coverage" zonder een run te verspillen. Dat is het verschil tussen een mutation-run die klaar is voordat je koffie op is, en eentje die de hele nacht draait.

De andere talen geven je hetzelfde:

  • cosmic-ray voor Python, aangestuurd vanuit je pytest- of unittest-suite.
  • cargo-mutants voor Rust, dat elke geplande mutant kan opsommen zonder één test te draaien, zodat je vooraf weet hoe groot de klus is.
  • Infection voor PHP, bovenop PHPUnit.

Ze rapporteren allemaal hetzelfde kerngetal, de mutation score, en wijzen allemaal de exacte regel en de exacte mutatie aan die overleefde.

Waarom is dit dan niet allang overal? Kosten en wrijving. De suite per mutant draaien is duur, en een engine per project opzetten, het configformaat leren en de output uitlezen is echt werk. Die wrijving is de drempel waar de meeste teams nooit overheen komen.

De workflow die wel werkt

AI is echt goed in de eerste versie van een test-suite. Laat AI het repetitieve werk doen. Geef het alleen niet het laatste woord.

Een workflow die standhoudt:

  1. Laat de AI de eerste tests genereren.
  2. Lees ze. Controleren ze gedrag, of voeren ze alleen regels uit?
  3. Draai mutation testing.
  4. Verbeter de tests tot de mutanten die ertoe doen dood zijn.
  5. Voeg de edge cases en business-scenario's toe waar het model nooit aan dacht.

Zo gebruik je AI om sneller te werken, terwijl mutation testing de kwaliteit bewaakt. Stap 1 en 5 zijn waar het model het meest helpt, en stap 3 houdt het geheel eerlijk.

De prompt was nooit de spec, en een groene testrun evenmin.

De loop in de agent zelf

Survivors met de hand nalopen is de eerlijke versie. Het is ook traag, en de AI die de zwakke tests genereerde zit er toch al, klaar om ze te repareren.

Daar heb ik dus een tool voor gebouwd: Chaos-MCP.

Het is een MCP server die precies de wrijving wegneemt die ik net beschreef. Je agent roept één tool aan. Chaos-MCP detecteert de taal, kiest de juiste engine (StrykerJS, cosmic-ray, cargo-mutants of Infection), draait de mutanten in een geïsoleerde sandbox (je echte workspace blijft ongemoeid), en zet de output om in een gesorteerde to-dolijst. De survivors komen terug, gesorteerd op ernst, elk met een uitleg waarom het gat gevaarlijk is en welke test hem zou doden.

De loop die het mogelijk maakt:

  1. Audit een bestand. Chaos-MCP geeft de overlevende mutanten terug plus een runId.
  2. De agent schrijft tests gericht op die survivors.
  3. Verifieer met diezelfde runId. Het draait alleen de vorige survivors opnieuw en meldt welke nu dood zijn.
  4. Herhaal tot het bestand schoon is.

Hetzelfde idee als de handmatige workflow, alleen moet de agent die de tests schreef nu bewijzen dat ze bugs vangen. Er is een tweede tool, triage_test_coverage, die een hele map zwakste-eerst rangschikt zodat je weet waar je moet beginnen, en een gate-modus die op basis van een minimumscore een pass/fail teruggeeft, zodat je CI erop kan blokkeren.

Nog wel dit: het is pre-release. Nog niet op npm, installeren doe je vanaf de source. De repo is openbaar en de README loodst je door de setup.

Ik draaide het deze week op een codebase en kreeg een bestand op 100% coverage met elke mutant gedood. Dat coverage-cijfer was de makkelijke helft. Chaos-MCP vertelde me dat de tests erachter ook echt iets betekenden.

Want daar is een test uiteindelijk voor.

Niet om groen te worden.

Maar om een bug tegen te houden voordat je gebruikers hem vinden.

serie: De AI-Skepticus(26 van 26)