Stel je voor: je hebt dag en nacht gewerkt om je nieuwe en revolutionaire functie klaar te krijgen voor je klant. Je hebt TDD gebruikt, je hebt getest, de code is gereviewd en je dashboard laat een verbazingwekkende 99% regel-, methode- en takdekking zien. Je implementeert je functie na acceptatie en gaat met een goed gevoel naar huis. De volgende dag kom je aan op je werk en - uit het niets - is je mailbox gevuld met testbevindingen van de klant... wat is er gebeurd? Na wat analyse van deze bevindingen blijkt dat de 99% regel- en takdekking niet heeft geleid tot een compleet beeld van de kwaliteit van de code. Hoe is dit mogelijk?
Wat ging er mis?
Waarom kunnen we niet vertrouwen op onze oude vrienden, lijndekking en takdekking? Nou, het probleem is dat een hoge regel- of takdekking geen echte indicatie is van de kwaliteit van je code. Laten we eens kijken naar drie problemen met regel- en takdekking:
Wat ontbreekt er?
Nou, er is geen controle op het aanroepen van de perform methode dus alle neveneffecten van deze methode (extra parameters die worden ingesteld, context die wordt veranderd...) worden niet gedekt door deze test.
Is deze test compleet?
Nee, er ontbreekt een controle op het grensgeval van een 0-waarde voor de variabele “i”, terwijl grenswaarden vaak een speciale betekenis hebben, apart gedrag kunnen veroorzaken of zelfs ongewenste uitzonderingen kunnen veroorzaken (delen door 0 enz.).
Wat is er mis met de twee bovenstaande tests?
De retourwaarde van methode foo wordt volledig genegeerd, terwijl een retourwaarde een expliciete betekenis heeft (anders zou deze niet worden gebruikt) en een directe invloed kan hebben op het resultaat van de processtroom. Om het nog concreter te maken, bekijk de onderstaande test, een test voor de power-functie. Wat kan er misgaan als dit je enige test is?
Antwoord: power() kan een optelling uitvoeren, een vermenigvuldiging, y tot de macht x uitvoeren in plaats van x tot de macht y, enz. en toch zou de test niet mislukken.
Wat hebben we nodig - is er een betere manier?
Een oplossing voor deze problemen is te vinden in mutatietesten. Bij mutatietesten gaat het om het testen van je tests, het controleren van de kwaliteit van je tests. Bij mutatietesten voer je je tests uit op licht aangepaste versies van je broncode. Zo'n aangepaste versie van je code wordt een mutant genoemd. Het eindspel is: alle mutanten - of zoveel mogelijk - gedood krijgen. Het doden van een mutant betekent dat ten minste één van je tests faalt op deze mutatie.
Mutatietesten is een vrij oud idee, het werd bedacht in de jaren 70 maar was tot voor kort niet erg mainstream omdat er een gigantische hoeveelheid mutanten gegenereerd kunnen worden op basis van je code. Dit maakt het uitvoeren van mutatietesten erg resource-intensief. Omdat computers tegenwoordig extreem krachtig zijn, wordt het concept steeds populairder.
Hoe werkt het?
Hoe werkt een mutatietest? Er wordt een boomstructuur van je broncode gemaakt en in alle knooppunten die voorwaarden, constante waarden voor variabelen, etc. bevatten kan een mutatie worden toegepast. Deze kunnen bijvoorbeeld het verwijderen of negeren van een voorwaarde inhouden.
Voor alle gegenereerde mutaties worden de unit tests uitgevoerd. Zodra een test faalt, wordt de mutant gedood en eindigt die test. Op die manier worden alle mutanten verwerkt en krijg je aan het eind een overzicht van het percentage gesneuvelde mutaties.
Een tool als PIT kan deze uitkomst gebruiken om een gedetailleerd rapport te genereren met de kwaliteit van je testen per class, package, etc.
Typen mutators
Wat zijn de verschillende soorten mutators? Hier zie je de belangrijkste types:
Je hebt - zoals al genoemd - de mutaties op condities, daarnaast heb je mutaties op wiskunde operatoren of logische operatoren. Als je kijkt naar terugkeerwaarden, kun je een booleaanse true vervangen door false, een numerieke waarde negeren, enz. en - in het geval van verzamelingen - kun je een mutatie maken die een lege verzameling retourneert in plaats van de bedoelde verzameling.
Hebben we perfectie nodig?
Hebben we perfectie nodig? Nee, dit is niet in alle gevallen nodig. Zoals je kunt zien in de schermafbeelding, die het genereren van een informatief bericht laat zien, mislukken mutatietesten omdat er geen test is voor het geconstrueerde bericht. Is dit een test die we absoluut nodig hebben? Misschien niet...
Extra voordelen
Wat heeft het verbeteren van de mutatiedekking ons opgeleverd, behalve dat we zonder angst voor de volgende dag van ons werk af kunnen😊?
Nou, we hebben een paar bugs ontdekt die niet waren gerapporteerd, we hebben tests toegevoegd die onvoorziene maar waarschijnlijke scenario's afdekten. We verwijderden ook dode code, onnodige controles,... Dus alleen al het uitvoeren van het proces leverde waarde op. Daarnaast werden onze tests robuuster en completer. Dus ik zou zeggen dat het een no-brainer is om dit soort testen toe te voegen aan het ontwikkelproces.
Alleen Java?
Is mutatietesten alleen beschikbaar voor Java-ontwikkeling? Nee, helemaal niet, hier vind je een aantal voorbeelden voor een hele reeks programmeertalen en frameworks:
A well-known mutation testing framework is Stryker, which is available for Javascript, Typescript, .NET en Scala.
References: