Mutation testing

Tegenwoordig hoor je er als programmeur niet meer bij als je geen unittesten schrijft. Sommige mensen zweren erbij dat je dit vooraf moet doen, TDD (Test Driven Development), anderen schrijven de testen liever achteraf of tegelijk. Natuurlijk meten we daarbij als echte professionals de code coverage van onze testen en daarmee tonen we aan hoe goed wij bezig zijn.

Maar wat nou als ik je vertel dat 100% code coverage eigenlijk niks betekent? Dat code coverage je alleen een vals gevoel van veiligheid geeft? Gelukkig is er een oplossing, rechtstreeks uit de jaren 70.

 

Waarom schrijven we unittesten?

Er zijn drie hoofdredenen dat we unittesten schrijven, namelijk:

  • Je hebt verificatie dat je code daadwerkelijk doet wat je hoopte/verwacht had;
  • Je hebt direct een automatische regressietest;
  • Geteste code is leesbaarder, vaak met minder afhankelijkheden.

Het feit dat jouw code wel of niet werkt, zou je natuurlijk kunnen controleren door de applicatie uit te voeren. Al kan dat soms pas veel later in het proces. Vaak weet je dan al lang niet meer waarom bepaalde code zo geschreven is. Tevens is dit handwerk en daarom dus niet herhaalbaar.

Het is zo dat de testen die je schrijft veel zeggen over de code. Heb je veel regels setup-code nodig? Is het misschien lastig om je klasse voor het testen te initiëren? Dan zit er waarschijnlijk iets verkeerd met de afhankelijkheden van deze klasse. Dat leidt weer tot het herschrijven en betere code. Maar hoe weet je nou dat alle scenario’s die beschreven zijn in de code ook zijn afgedekt door een test? De fervente aanhangers van TDD zullen nu in koor roepen: code coverage!

 

Code coverage

Als je een test draait, zal een deel van je code uitgevoerd worden. Met slimme bytecode manipulatie kunnen tools (zoals JaCoCo: http://www.eclemma.org/jacoco/) meten welke regels code uitgevoerd worden door de test. Hiermee kan een beeld opgebouwd worden van de code coverage, de dekking van de testen. In de meeste gevallen wordt zelfs gemeten welke ‘branches’ (verschillende paden die in de code doorlopen kunnen worden, bijvoorbeeld bij een if-then-else) geraakt worden.

Een voorbeeld hiervan:


if(java != null && java.magazine())

 

In dit geval zijn er drie unieke scenario’s mogelijk:

  1. java is null (de rest van de expressie wordt uiteraard niet geëvalueerd);
  2. java is niet null en java.magazine() is false;
  3. java is niet null en java.magazine() is true.

Vaak zal alleen het eerste en derde scenario getest worden. JaCoCo zal dan aangeven dat de branch coverage slechts 75% is. Van object java is beide mogelijke statussen getest (null en niet null), bij java.magazine() slechts één.

 

De problemen met code coverage

Veel programmeurs denken dat het gebruik van code coverage de perfecte manier is om te controleren of je alle paden hebt getest. Maar er zijn twee grote problemen die je met code coverage niet ziet.

Risico 1: Verborgen aanroepen

Stel we hebben we volgende code:


public void validate(Some input) {
   
    if(input.foo()) {
        belangrijkeZaken();
        return true;
    }
    return false;
}

 

In dit geval zal er vaak twee tests worden geschreven, de eerste test heeft input.foo() als false, de tweede test heeft input.foo() als true. Dit zorgt voor 100% test coverage! Maar hoe controleren we eigenlijk dat de call naar belangrijkeZaken() is gedaan?

 

Risico 2: Missende assertions

Als je een test schrijft, zal je (als het goed is) altijd de volgende drie blokken code hebben:

 

Given:
Je geeft de te testen code de benodigde input;
When:
Je voert de code uit;
Then:
Je controleert het resultaat.

 

Elke programmeur heeft wel eens een test gezien, waarschijnlijk zelfs geschreven, waarbij uiteindelijk helemaal geen Assert wordt gedaan. Belachelijk natuurlijk, want wat heb je aan een test die niks test? Deze zal met geen mogelijkheid falen. Met code coverage controleren we alleen het given-when gedeelte, maar nooit de then! Dit is misschien wel direct het grootste probleem met code coverage: je controleert nooit de assertions, het belangrijkste gedeelte van je test!

Persoonlijk heb ik projecten meegemaakt waar, mede door de mooie dashboards van SonarQube, het hebben van code coverage een management doel was geworden. “Jullie moeten code coverage van 90% of hoger hebben.” Dit zorgde er alleen nooit voor dat de kwaliteit omhoog ging. Het zorgde er alleen voor dat er doelloze testen geschreven werden, alleen om de coverage omhoog te krijgen. Daar doen wij, serieuze programmeurs, natuurlijk niet aan mee!

Gelukkig is er een betere manier om onze testen te controleren, waarin je niet eens kan vals spelen. Een manier die niet alleen controleert dat de code wordt geraakt door een test maar die juist onze aannames controleert: mutation testing.

 

 

Mutation testing

In plaats van het testen van je code doet mutation testing iets vreemds. Eerst worden er honderden, soms wel duizenden zogenaamde ‘mutants’ gemaakt van je code. Hier worden dan de unittesten tegen gedraaid.

 

Wat is een mutant?

Een mutant is een versie van de code waarin deterministisch een kleine atomaire wijziging is gedaan. Bijvoorbeeld:


if(java == null)

 

zal worden aangepast naar:


if(java != null)

 

Na het maken van deze ‘mutant’ draaien we alle unittesten en bekijken we de uitkomst.

 

En dan…?

Nu komt het geniale gedeelte van mutation testing: Als je alle testen correct geschreven hebt, moet er een test falen! In de terminologie van mutation testing is deze mutant dan gekilled. Het idee is dat elke mutatie (die vergelijkbaar zijn met daadwerkelijke bugs) leidt tot een falende test.

 

 

Als dit niet het geval is, er valt geen enkele test om, dan is de mutant nog steeds alive. Dit is een groot probleem. Mocht deze bug daadwerkelijk in de code zou zitten, of later worden geïntroduceerd, dan zal het niet gevonden worden door de huidige set aan unittesten.

 

Mutation testing in Java

In het Java landschap is er een aantal frameworks die automatisch de mutation coverage voor je opmeten. Helaas zijn de meeste frameworks weinig tot niet meer in ontwikkeling. Voorbeelden hiervan:

 

PIT framework

Gelukkig is er tegenwoordig het PIT framework.

Dit framework heeft een brede technische ondersteuning en is nog volop in ontwikkeling. Met PIT heb je dezelfde voordelen als met code coverage. Het gebruik ervan is gemakkelijk en volledig automatisch. Het draait mee in je build (bijvoorbeeld met Gradle, Maven of Ant), er zijn overzichten voor SonarQube en er zijn IDE plugins zodat je meteen in IntelliJ of Eclipse de resultaten kan zien.

 

Mutators

In het PIT framework stel je een aantal mutators in, dit zijn regels die bepalen welke mutants gemaakt worden. Soms denkt men dat mutation testing willekeurig een paar wijzigingen doet. De gemaakte mutants en de uitkomsten van PIT zijn altijd volledig deterministisch en herhaalbaar. Elke run zal dezelfde resultaten opleveren.

 

Alle mutants worden runtime gemaakt op basis van ASM bytecode manipulatie. De gemuteerde code (incl. fouten) worden nooit opgeslagen op je harde schijf, je hoeft dus niet bang te zijn dat er mutanten ontsnappen en chaos creëren op je productieserver.

Het gebruik van andere bytecode generatie-tools, zoals bijvoorbeeld mocking frameworks, is geen probleem. PIT werkt prima samen met JMock, EasyMock, Mockito, PowerMock en JMockit.

Een paar voorbeelden van mutators:

* Conditional boundary (controleert de zogenaamde off-by-one errors)
> wordt >=
< wordt <=
(etc)
* Negate conditionals (controleert de tegenstellingen)
!= wordt ==
< wordt >=
<= wordt >
(etc)
* Math mutators (veranderd berekeningen)
+ wordt –
/ wordt *
(etc)
* Void method removal
Haal een void-aanroep weg (hiermee vang je het bovenstaande risico #1 af)

Kijk voor de complete lijst hier.

 

Voordelen

Wat zijn nu de voordelen van mutation testing ten opzichte van het meten van code coverage? De focus ligt vooral op logica en berekeningen, daar zullen de meeste mutaties gedaan worden. Dit komt overeen met waar de meeste fouten ontstaan in echte code. Met mutation testing controleer je ook daadwerkelijk de assertions. Met code coverage kan je 100% halen zonder een enkele assertion te schrijven. Dit kan simpelweg niet met mutation testing. De enige manier om 100% mutation coverage te halen is door daadwerkelijk goede testen te hebben die falen als de logica verandert.

 

Nadelen

Helaas zitten er ook nadelen aan het gebruik van mutation testing. De kwaliteit van de tooling is nog niet zo ‘gelikt’ als bij code coverage. Bijvoorbeeld de Eclipse plugin is erg slecht, ook zijn de gegenereerde rapportages (in HTML) niet zo mooi als bij code coverage.

Maar… het allergrootste probleem is de performance. Liever gezegd: was de performance.

 

Het concept van mutation testing is niet nieuw, het werd voor het eerst beschreven in 1971. Het probleem was echter dat het draaien van alle unittesten soms een hele dag (of zelfs een week) duurde. Het was dus onmogelijk om dit te doen voor honderden gemuteerde versies van dezelfde code. Na een korte opleving begin jaren ‘70 is het gebruik van mutation testing langzaam afgenomen, met name door de problemen met de performance.

Tegenwoordig zijn de processoren een stuk sneller, vaak ben je binnen enkele seconden klaar met de unittesten. Daarnaast heeft PIT een aantal slimme trucs in petto die het gebruik ervan nog sneller maakt.

 

Code coverage

Een van de manieren die zorgt voor betere performance is verrassend genoeg: code coverage. Als meetinstrument is code coverage erg slecht, maar als onderdeel van mutation testing zorgt het voor een gigantische snelheidswinst.

PIT draait eerst alle testen zonder mutaties en meet daarbij per test de code coverage. Als er op een regel code een mutatie wordt gemaakt, moeten dan alle unittesten opnieuw gedraaid worden? Nee, alleen de testen die initieel deze regel raakte hoeven opnieuw gedraaid te worden. De andere testen zullen namelijk nooit op de gemuteerde regel komen en dus niet ineens falen.

Incremental analysis (cache van testresultaten)

Nieuw in PIT is het bewaren van testresultaten. Als een klasse niet gewijzigd is en er geen nieuwe testen zijn die de klasse raken, waarom zouden er dan opnieuw mutaties gemaakt moeten worden?

Door het resultaat na de vorige testen te bewaren (inclusief hash van de code) zal PIT alleen de eerste keer iets langzamer zijn. Hierna kan PIT incrementeel de resultaten bijwerken. De overhead per build wordt op deze manier erg klein.

 

Conclusie

De conclusie die we kunnen trekken, is dat code coverage eigenlijk een slechte manier is om de kwaliteit van je testen te meten. Want: heb je geen code coverage? Dan moet je zeker aan de slag. Heb je wel code coverage? Dan is er waarschijnlijk een poging gedaan om een goede test te schrijven, maar eigenlijk weten we nog steeds helemaal niks.

Wil je echt weten hoe het ervoor staat met de kwaliteit van de unittesten? Start PIT, of een van de andere mutation test tools, en bekijk de resultaten.