PIT-mutatietesten
Mutation testing in Java: een praktisch voorbeeld met PIT
Test coverage wordt vaak gebruikt als indicator voor softwarekwaliteit, maar een hoog percentage betekent niet automatisch dat je tests ook echt effectief zijn.
Dat is precies wat ik ontdekte toen ik voor het eerst met mutation testing werkte. Door bewust kleine wijzigingen in de source code aan te brengen en te controleren of bestaande tests die detecteren, geeft mutation testing een andere kijk op de kwaliteit van je tests.
In deze blog laat ik aan de hand van een praktisch voorbeeld zien hoe dit werkt met Java, JUnit en PIT.
JUnit, regression testing en mutation testing
In eerdere projecten was ik gewend om te werken met een test coverage van ongeveer 70–90%. Bij een van onze klanten gingen de eisen echter verder. Voordat code naar de develop branch kon worden gemerged, werkten we met:
- JUnit tests
- Regression tests
- PIT mutation tests
Het doel was om voor deze testvereisten 100% te behalen voordat code werd gemerged.
Regression testing met Cucumber
Java en JUnit zijn voor ons bij Yuma bekend terrein, dus daar ga ik hier niet uitgebreid op in.
Bij deze klant werden regression tests uitgevoerd met Cucumber. Met Cucumber kun je functional validation automatiseren in een format dat eenvoudig te lezen en begrijpen is voor onder andere Business Analysts, Developers en Testers.
Cucumber voert tests uit die geschreven zijn in Gherkin, een gestructureerde plain-text language die in natural language kan worden geschreven.
Hoe werkt Gherkin?
In een Gherkin file kunnen regels beginnen met specifieke keywords, gevolgd door een beschrijving in natural language. Twee van de belangrijkste keywords zijn:
- Feature: een high-level beschrijving van de softwarefunctionaliteit die wordt getest. Je kunt dit zien als een beschrijving van een use case.
- Scenario: een concreet voorbeeld dat een business rule of verwacht gedrag beschrijft.
Voorbeeld van een Gherkin scenario
Scenario Outline: Successful login to Searchlight as a legit Searchlight user
Given I am on the "Login" page
When I fill in "username" field with "foo@mydomain.com"
And I fill in "password" field with "CucumberIsMagic"
And I click "submit" button
Then I should be logged in successfully as "foo@mydomain.com"Wat is mutation testing?
Dit was voor mij het interessante gedeelte, omdat ik mutation testing daarvoor nog niet in de praktijk was tegengekomen.
Mutation testing is een vorm van software testing waarbij bewust kleine wijzigingen, of mutations, in de source code worden aangebracht. Vervolgens wordt de bestaande test suite uitgevoerd om te controleren of de tests deze wijzigingen detecteren.
Het doel is om de kwaliteit en robuustheid van je tests te beoordelen, in plaats van alleen te meten hoeveel van je source code wordt uitgevoerd.
Waarom code coverage alleen niet genoeg is
Traditionele test coverage metrics, zoals line coverage, statement coverage en branch coverage, meten welke delen van je code door je tests worden uitgevoerd.
Ze vertellen je echter niet automatisch of die tests ook in staat zijn om fouten te detecteren in de code die wordt uitgevoerd.
Code kan daardoor een hoge test coverage hebben en toch maar gedeeltelijk getest zijn. Een extreem voorbeeld is een test zonder assertions. Vaker komt het voor dat tests het normale pad wel afdekken, maar belangrijke boundary conditions of alternatieve scenario's missen.
PIT mutation testing in de praktijk
PIT, ook wel Pitest genoemd, brengt mutations aan in je Java code en controleert vervolgens of je bestaande unit tests deze detecteren.
Neem bijvoorbeeld deze eenvoudige methode:
public boolean isPositive(int number) {
boolean result = false;
if (number >= 0) {
result = true;
}
return result;
}De methode beschouwt nul en alle positieve getallen als positief. PIT kan kleine wijzigingen in deze code aanbrengen en vervolgens onze tests uitvoeren op de gewijzigde versies.
Mutation 1: de conditional boundary wijzigen
Een mogelijke mutation is het wijzigen van >= naar >:
public boolean isPositive(int number) {
boolean result = false;
// Mutator: changed conditional boundary
if (number > 0) {
result = true;
}
return result;
}De methode gedraagt zich nu anders wanneer de input nul is.
Mutation 2: de conditional negaten
Een andere mutation kan het gedrag van de condition volledig veranderen:
public boolean isPositive(int number) {
boolean result = false;
// Mutator: negated conditional
if (false) {
result = true;
}
return result;
}Mutations killen met betere unit tests
Binnen mutation testing wordt een mutation als killed beschouwd wanneer een van je tests faalt door de aangebrachte wijziging.
Als alle tests nog steeds slagen, heeft de mutation survived. Dat kan erop wijzen dat je tests het betreffende gedrag niet goed genoeg controleren.
Kijk bijvoorbeeld naar deze JUnit test:
@Test
public void testPositive() {
CalculatorService obj = new CalculatorService();
assertEquals(true, obj.isPositive(10));
}Deze test detecteert de tweede mutation, omdat de gewijzigde condition ervoor zorgt dat de methode voor 10 niet langer true teruggeeft.
De eerste mutation blijft echter bestaan.
Waarom? Omdat de test alleen de waarde 10 gebruikt. Zowel number >= 0 als number > 0 geven voor deze input hetzelfde resultaat.
De boundary condition testen
Om de eerste mutation te killen, moet de test ook de boundary value testen: nul.
De gemuteerde versie ziet er als volgt uit:
public boolean isPositive(int number) {
boolean result = false;
// Mutator: changed conditional boundary
if (number > 0) {
result = true;
}
return result;
}We kunnen de unit test verbeteren door een assertion voor nul toe te voegen:
@Test
public void testPositive() {
CalculatorService obj = new CalculatorService();
assertEquals(true, obj.isPositive(10));
// Kill mutation 1
assertEquals(true, obj.isPositive(0));
}De test maakt nu onderscheid tussen > en >=. Daardoor wordt de gewijzigde conditional boundary gedetecteerd.
Het resultaat: 100% mutation coverage voor dit voorbeeld.
Wat mutation testing laat zien wat code coverage niet laat zien
Dit eenvoudige voorbeeld laat duidelijk de waarde van mutation testing zien.
De oorspronkelijke unit test voerde de conditional uit en droeg dus bij aan de traditionele code coverage. Toch werd het gedrag van die conditional niet volledig getest.
Mutation testing maakte de ontbrekende boundary case zichtbaar.
Kort samengevat:
- Code coverage vraagt: hebben je tests deze code uitgevoerd?
- Mutation testing vraagt: zouden je tests het merken als deze code zich anders gedraagt?
Waarom PIT gebruiken voor mutation testing in Java?
Er bestaan andere mutation testing systems voor Java, maar PIT is ontwikkeld met praktische software development in gedachten.
Enkele praktische voordelen zijn:
- Het kan een codebase relatief snel analyseren.
- Het genereert reports waarin line coverage en mutation coverage worden gecombineerd.
- Het kan worden geïntegreerd met build tools zoals Ant, Maven en Gradle.
- Er zijn plugins beschikbaar voor development environments zoals Eclipse en IntelliJ IDEA.
Wat ik heb geleerd van mutation testing
Voordat ik aan dit project werkte, had ik mutation testing nog niet in de praktijk gebruikt.
Mijn belangrijkste inzicht was dat een hoge code coverage niet automatisch betekent dat je goede tests hebt. Mutation testing voegt een extra laag toe door te controleren of je tests daadwerkelijk betekenisvolle wijzigingen in het gedrag van je applicatie kunnen detecteren.
In dit voorbeeld miste een op het eerste gezicht prima unit test een belangrijke boundary condition. PIT maakte dat probleem zichtbaar en hielp om de test robuuster te maken.
Dat is ook een van de interessante kanten van werken aan verschillende projecten: af en toe kom je een techniek tegen die je anders laat kijken naar iets waarvan je dacht dat je het al goed kende.