Mutation-Testing mit PIT: Teste deine Tests!

Automatisierte Tests und testgetriebene Entwicklung sind glücklicherweise aus der professionellen Software-Entwicklung nicht mehr wegzudenken. Deutlich kürzere Releasezyklen zwingen zur Automatisierung von Qualitätssicherungs-Maßnahmen. Dennoch treten Fehler in Live-Umgebungen auf, die durch das Raster der Tests gefallen sind. Neben der Erfassung der Anzahl von Testfällen entsteht die Notwendigkeit, die Qualität der Tests zu messen. Während die Strategien zur Ermittlung der Testabdeckung etabliert sind, führen Mutation-Tests immer noch ein Schattendasein. Zu Unrecht! Ermöglichen sie doch eine fundierte Aussage über die Qualität der Tests.
Viel hilft nicht immer viel
Die einfachste Metrik für Tests ist die Testabdeckung. Der Begriff wird nicht nur in der Software-Technik, sondern auch im Maschinenbau verwendet und bezeichnet den Quotienten aus getroffenen Aussagen eines Tests mit den theoretisch möglichen treffbaren Aussagen [1]. In der Software-Entwicklung ist eine treffbare Aussage ein Codeteil im Produktionscode. Getroffen ist die Aussage, wenn der Code während des Testlaufs ausgeführt wurde. Wann der Code tatsächlich als angelaufen gilt, legen unterschiedliche Ausprägungen der Testabdeckung fest:
- Function Coverage: Die treffbaren Aussagen sind alle Methoden/Funktionen der Software. Jede Funktion gilt als getroffen, wenn sie mindestens einmal aufgerufen wurde.
- Statement Coverage: Treffbar sind die Statements der Software, meistens sind das die Zeilen des Quellcodes. Als getroffen gelten sie, wenn sie mindestens einmal während des Testlaufs aufgerufen wurden.
- Branch Coverage: Als treffbare Aussagen gelten hier alle Verzweigungen der Software. Um eine vollständige Abdeckung zu erreichen, müssen alle Zweige im Testlauf angelaufen werden.
Tools wie zum Beispiel Jacoco im Java-Umfeld werten die Testabdeckung schnell und zuverlässig aus [2]. CI/CD-Pipelines analysieren die aktuelle Testabdeckung und können den Build abbrechen, wenn sich der Wert verschlechtert hat. Aber ist eine hohe Testabdeckung wirklich ein Garant für fehlerfreie Software?
Falsche Sicherheit
Die folgende einfache Java-Methode filtert aus einer Liste von Personen alle mit einem Alter oberhalb einer gegebenen Grenze:
public List<Person> findPersonsOlderThan(int olderThan, List<Person> persons) {
List<Person> result = new ArrayList<>();
for (Person p : persons) {
if (p.age > olderThan) {
result.add(p);
}
}
return result;
}
Allen Java-Profis sei versichert, dass im späteren Verlauf des Beitrags eine Variante der Methode unter Verwendung der Stream-API folgt. Für die Methode besteht bereits ein Test, der ein etwaiges späteres Refactoring absichert:
private static final List<PersonService.Person> PERSONS = List.of(
new PersonService.Person("Madge", "Domone", 15),
new PersonService.Person("Clywd", "Mudle", 15),
new PersonService.Person("Joela", "Danielian", 36),
new PersonService.Person("Ada", "Keiley", 56),
new PersonService.Person("Reynold", "McLanaghan", 10),
new PersonService.Person("Jamal", "Howley", 60),
new PersonService.Person("Mireille", "De Haven", 19),
new PersonService.Person("Horatius", "Alwood", 19),
new PersonService.Person("Cornall", "Plowman", 36),
new PersonService.Person("Stillmann", "Kighly", 2)
);
@Test
public void testPersonsOlderThan() {
PersonService personService = new PersonService();
assertThat(personService.findPersonsOlderThan(57, PERSONS),
is(List.of(new PersonService.Person("Jamal", "Howley", 60))));
assertThat(personService.findPersonsOlderThan(5, PERSONS), hasSize(9));
}
Der Test ruft die Methode mit einer Liste von Beispielpersonen zweimal auf. Einmal erfolgt eine Prüfung auf eine bestimmte Person, beim zweiten Aufruf wird nur die Anzahl der erwarteten Ergebnisse geprüft. Die Analyse durch Jacoco zeigt eine volle Testabdeckung unter der strengsten Metrik, der Branch-Coverage.
Die grünen Rauten in den Zeilen 12 und 13 signalisieren, dass bei der Testausführung tatsächlich alle Pfade angelaufen wurden. Die Branch-Coverage oder Pfadabdeckung des Produktionscodes durch Tests liegt also bei 100 Prozent.
Refactoring
Jeder ernstzunehmende Ratgeber über das Thema Refactoring empfiehlt, vor konkreten Umbaumaßnahmen die Testabdeckung zu prüfen. In obigem Beispiel sind die Bedingungen optimal, dem Umbau der Methode unter Zuhilfenahme von Java-Stream steht nichts mehr im Wege.
public List<Person> findPersonsOlderThan(int olderThan, List<Person> persons) {
return persons.stream()
.filter(p -> p.age >= olderThan)
.toList();
}
Wie in der testgetriebenen Entwicklung üblich erfolgt nach jeder Codeänderung ein Testdurchlauf. Der Test ist weiterhin grün. Das Refactoring war also erfolgreich! Oder?
Tatsächlich hat sich bei der Umformulierung der Methode ein Fehler eingeschlichen: Aus der Bedingung p.age > olderThan wurde ein p.age >= olderThan. Insbesondere ist die Testsuite weiterhin grün, der bestehende Test schlägt nicht fehl. Solch ein Fehler kann in einer gründlichen Code-Review auffallen – im Rahmen umfangreicher Anpassungen geht er aber leicht unter, er überlebt also.
Code mutieren lassen
Tatsächlich nutzen Mutation-Testing-Frameworks auch den Begriff des Überlebens. Um zu verstehen, was die Tools tun, ist ein Blick auf die Funktionsweise sinnvoll. Mutation-Testing nimmt Software-Entwickler:innen die unangenehme Aufgabe ab, den eigenen Quellcode mit martialischen Mitteln anzugreifen. Dazu führen sie folgende Schritte aus:
- Das Framework führt die gesamte Testsuite aus.
- Es stellt fest, welcher Teil des Produktionscodes durch welche Tests abgedeckt wird.
- Das Framework mutiert einen Teil des Produktionscodes, baut also mutwillige Fehler in den Code ein und kompiliert ihn.
- Alle in Schritt 2 festgestellten Tests für diesen Teil des Produktionscodes werden erneut ausgeführt.
Falls der Test grün ist, hat die Mutation überlebt (nicht erwünscht).
Falls der Test fehlschlägt, hat er die Mutation gekillt (erwünscht).
Insbesondere der letzte Schritt hat es in sich und erfordert ein starkes Umdenken: Der erwünschte Fall ist tatsächlich der Fehlschlag des Tests, denn nur in diesem Fall deckt der Test den in den Produktionscode eingebauten Fehler auf. Doch welche Art von Fehler bauen die Tools in den Code ein?
Mutatoren
Das Mutation-Testing-Framework PIT ermöglicht einen einfachen Einstieg in das Mutation-Testing für Java-Anwendungen. Ursprünglich ging das Framework aus einem Spike hervor, mit dem die parallele Testausführung in JUnit erreicht werden sollte. Die Abkürzung stand damals für "Parallel Isolated Tests". Das Tool entwickelte sich in der Folge jedoch in Richtung Mutation-Testing, behielt den Namen PIT oder PITest jedoch einfach ohne tieferen Sinn bei [3].
PIT bringt diverse Mutatoren mit, deren Ausführung per Konfiguration aktiviert oder unterdrückt werden kann.
- Conditional Boundary mutiert alle Größer- oder Kleiner-Vergleiche dahingehend, dass aus einem Größer ein Größer/Gleich wird, aus einem Kleiner/Gleich ein Kleiner usw.
- Increments vertauscht Inkrementieren und Dekrementieren, aus jedem i++ wird also ein i-- und andersherum.
- Invert Negatives gibt jeder Variablen, die mit einem negativen Wert initialisiert wird, einen positiven Wert. Der Mutator streicht also einfach das Minuszeichen.
- Math vertauscht mathematische und logische Operatoren gemäß einer Tabelle. So wird beispielsweise aus jedem + ein – oder aus jedem | ein &.
- Void Method Call löscht alle Methodenaufrufe, die keinen Wert zurückgeben, aus dem Produktionscode.
- Empty Returns ersetzt den tatsächlichen Wert einer zurückgegebenen Variable durch einen sinnvollen "leeren" Wert für den Typ. Für Listen wird beispielsweise Collections.emptyList() zurückgegeben, für Zahlen der Wert 0.
Die Website von PIT stellt eine ausführliche Übersicht über die implementierten Mutatoren und die auswählbaren Standardgruppen dar [4].
Einbindung von PIT
Für Maven und Gradle stellt PIT Plugins zur Verfügung, die sich per Konfiguration in den Lifecycle integrieren lassen. Für Maven erfolgt die Konfiguration in der pom.xml:
<build>
<plugins>
...
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.5</version>einmal
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>
pitest-junit5-plugin
</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
Sofern im Projekt JUnit 4 zum Einsatz kommt, kann der Dependencies-Block komplett entfallen. Für JUnit 5 und TestNG ist die Konfiguration der passenden Plugin-Abhängigkeit notwendig, sonst findet PIT bei der Ausführung keine Tests und startet keinen Mutationsdurchlauf. Nach der Konfiguration ist PIT bereit für den ersten Start. Hier ist zu beachten, dass neben dem PIT-Plugin noch mindestens die test-compile-Phase gelaufen sein muss. Mit diesem Befehl glückt der Start in jedem Fall:
$ ./mvnw test-compile pitest:mutationCoverage
Ab diesem Zeitpunkt ist Geduld gefragt. Selbst überschaubare Systeme benötigen für den ersten Durchlauf eher Stunden als Minuten. Nach erfolgreicher Ausführung legt PIT jedoch einen sehr aufschlussreichen Report ab. Dieser beantwortet nun endlich die eingangs gestellte Frage nach der Testqualität jenseits der reinen Testabdeckung.
Der Mutation-Report
In dem einfachen Beispiel der Java-Methode zur Filterung einer Personenliste hatte sich im Refactoring ein Fehler eingeschlichen, den der bestehende Test nicht entdeckt hat. PIT gelingt es jedoch, das Problem zu erkennen. Dies schlägt sich in der "Test Strength" für das Package im Report nieder, diese liegt nur bei 75 Prozent.
Die Test Strength ist der Quotient aus gekillten und insgesamt angewendeten Mutationen. Die Detailansicht des Reports verrät, dass PIT auf der Klasse PersonService vier Mutationen angewendet hat, von denen eine überlebt hat. Drei hat der Test folglich gekillt, das führt zum Wert von 75 Prozent für die Test Strength.
Wenig überraschend hat der Test den Conditional Boundary Mutator nicht überlebt. PIT hat aus der Bedingung p.age >= olderThan ein p.age > olderThan gemacht. Bei der folgenden Testausführung blieb der Test grün, hat also die Mutation nicht entdeckt. Die anderen drei angewendeten Mutationen führten allesamt zu einem Fehlschlag beim folgenden Testdurchlauf und gelten daher als gekillt. Der Report listet zudem sehr übersichtlich auf, welche Mutatoren aktiv waren, sowie die Tests, die den Produktionscode abdecken.
PIT erweckt den Anschein eines sehr einfach konfigurierbaren Tools, das zudem sehr übersichtlich Aufschluss über Schwachstellen im Testcode liefert. Wo liegen also die Stolpersteine bei der Verwendung?
Stolperstein 1: Zeitdruck
Wie oben bereits angedeutet, sind die Durchlaufzeiten für das Mutation-Testing selbst bei überschaubaren Projekten sehr hoch. Für die ersten Gehversuche mit PIT sollte daher ausreichend Zeit zur Verfügung stehen, um nicht unter Druck zu geraten. Für ein Projekt mit einer Laufzeit von 45 Sekunden für die gesamte Testsuite aus gut 180 Tests betrug die Laufzeit von PIT ca. 45 Minuten. Die gute Nachricht: PIT stellt hilfreiche Methoden zur Optimierung bereit. Dazu folgen weiter unten konkrete Tipps.
Stolperstein 2: Instabile Tests
Damit PIT funktioniert, muss die Testsuite absolut stabil sein. Das heißt insbesondere, dass alle Tests isoliert sein müssen und es keine Flaky-Tests geben darf. Volle Isolation von Tests ist dann erreicht, wenn alle Tests einzeln, mit der umgebenden Klasse oder in der gesamten Suite in beliebiger Reihenfolge ausgeführt, stets das gleiche Ergebnis liefern. PIT führt die Tests nach jeder Mutation aus, also letztendlich deutlich häufiger als in der regulären Testsuite. Ohne saubere Isolation kommt es hier schnell zu Fehlern. Noch schlimmer sind Flaky-Tests, also Tests, die "hin und wieder" fehlschlagen. Was sich in einer Pipeline noch durch einen Neustart umschiffen lässt, crasht jeden PIT-Durchlauf.
Problematisch ist das vor allem in Legacy-Projekten mit instabilen Tests. Hier wäre eine Einschätzung der Testqualität eine große Hilfe, oftmals erfordern die Tests jedoch eine grundlegende Überarbeitung, um einen stabilen PIT-Durchlauf ausführen zu können.
Stolperstein 3: Scope und Interpretation
Wie oben beschrieben, stellt PIT im ersten Schritt fest, welcher Teil des Produktionscodes durch welchen Test abgedeckt ist. Dieser Produktionscode wird mutiert und die zuvor ermittelten Tests dürfen versuchen, die Mutationen zu killen. Das alles mit dem Ziel, die Qualität der Tests zu messen. Insbesondere bezieht sich die Aussage zur Qualität natürlich ausschließlich auf bestehende Tests. Daher gehen in eine seriöse Interpretation der Ergebnisse von PIT immer zwei Parameter ein:
- Testabdeckung/ Line Coverage
- Test Strength, d. h. Anteil der durch die Tests gekillten Mutationen
Hat eine Anwendung lediglich 10 Prozent Testabdeckung mit einer Test Strength von 100 Prozent, sind immer noch 90 Prozent des Produktionscodes ungetestet – die Qualität der bestehenden Tests schließt diese Lücke nicht. PIT greift dies in der Metrik der "Mutation Coverage" auf. Sie bezeichnet, wie viele der möglichen Mutationen tatsächlich gekillt wurden. Anweisungen im Produktionscode, die mutiert werden können, die aber keine Testabdeckung haben, gelten als nicht gekillt.
Im Gegensatz zur reinen Testabdeckung eignet sich die Mutation Coverage jedoch deutlich besser zur Messung des Grades, in dem ein Projekt testgetrieben entwickelt wurde. So bringen Tests ohne Assertions die Abdeckung nach oben, haben aber keinen realen Nutzen. Dies wird per Metrik nur durch ein Absinken der Mutation Coverage aufgedeckt. Die Testabdeckung verbessert sich und vermittelt eine trügerische Sicherheit.
Was sind – abgesehen von den Stolpersteinen – sinnvolle Tipps für die ersten Schritte mit PIT?
Tipp 1: Lies den Quickstart-Guide!
Besonders der erste Durchlauf von PIT benötigt viel Zeit. Abgesehen von den Optimierungsmöglichkeiten für folgende Durchläufe bietet die Wartezeit die hervorragende Möglichkeit, den Quickstart-Guide eingehend zu studieren [5]. Der Funktionsumfang von PIT ist angenehm überschaubar, dennoch bietet das Tool ausreichend Möglichkeiten zum Finetuning auf die eigenen Projektherausforderungen.
Der Quickstart-Guide bietet einen guten Überblick über Konfigurationsmöglichkeiten und Hintergrundinformationen. Dennoch ist er so knapp gehalten, dass sich eine komplette Lektüre anbietet und relativ schnell bezahlt macht. So verrät der Guide, durch welche Parameter PIT die Mutation auf bestimmte Packages oder Muster im Produktions- oder Testcode einschränkt. Oder wie eine beschleunigte Analyse nur für Dateien, die im verwendeten Sourcecode-Managementsystem geändert wurden, ausführbar ist.
Tipp 2: Schalte das Verbose-Logging ein!
Vor allem der erste Lauf von PIT ist nervenzehrend: Das Tool gibt nach dem Start keine Zwischenmeldung über den Fortschritt aus. Insbesondere gibt es keinen Anhaltspunkt, wie lange "lang" ist, wann also mit dem Abschluss des Mutation-Test-Durchlaufs zu rechnen ist. Bei allem Vertrauen stellt sich auch den Geduldigsten unter uns nach spätestens einer halben Stunde die Frage, ob das Tool überhaupt noch arbeitet oder mittlerweile den Dienst quittiert hat. Als vertrauensbildende Maßnahme bietet sich die Verbose-Logging-Option an. Der Parameter ‑Dverbose=true aktiviert sie und führt zu einem Logging der einzelnen Testdurchläufe nach den Mutationen. Letztendlich verlängert diese Ausgabe den Mutation-Testing-Durchlauf noch einmal, für die ersten Gehversuche hilft das Wissen aber, dass die Konfiguration korrekt ist und PIT im Hintergrund arbeitet.
Tipp 3: Optimiere deine Tests!
Kurz gesagt: Je schneller die Tests an sich laufen, desto schneller läuft auch das Mutation-Testing. Frameworks wie Spring Boot optimieren die Ausführung der Testsuites dadurch, dass teure Schritte möglichst nur einmalig ausgeführt werden. Spring verwendet einen einmal hochgefahrenen Application Context für die gesamte Testsuite. Diese Wiederverwendung führt zu moderaten Laufzeiten der Testsuites, selbst wenn viele Testklassen und Methoden beteiligt sind.
Für PIT fällt diese Optimierung leider nicht ins Gewicht, da das Framework die Tests nach jeder Mutation erneut startet. Der teure Teil der Initialisierung des Application Contextes fällt schlimmstenfalls nach jeder Mutation an und verlängert die gesamte Laufzeit deutlich.
Abhilfe schaffen Optimierungen an der Ausführungszeit der Testsuite an sich: Klassische Unit-Tests benötigen eventuell gar nicht den kompletten Kontext für die Ausführung. In anderen Tests kann die teure Testdatengenerierung für die gesamte Testklasse geschehen und muss nicht für jede Testmethode einzeln ausgeführt werden. Angenehmer Nebeneffekt: Durch Optimierungen an dieser Stelle läuft jede CI/CD-Pipeline ein bisschen schneller.
Tipp 4: Optimiere PIT!
Eine der einfachsten Optimierungen für PIT-Durchläufe ist die Verwendung der History. PIT speichert bei Anwendung der Option ‑DwithHistory=true Dateihashes an einem konfigurierbaren Speicherort. Nachfolgende Durchläufe ermitteln anhand dieser Hashes, ob eine erneute Ausführung von Mutation und Test notwendig ist und überspringt sie gegebenenfalls. Der Performance-Gewinn ist beeindruckend: In einem Beispiel benötigte PIT 45 Minuten für den ersten Durchlauf, der durch die History-Daten gestützte zweite Durchlauf erfolgte in nur noch 45 Sekunden!
Der zugrundeliegende Mechanismus, die Incremental Analysis, ist weiterhin im experimentellen Stadium [6].
Tipp 5: Starte PIT nicht einfach in CI/CD-Pipelines!
Die meisten CI/CD-Pipelines dürften so konfiguriert sein, dass generierte Artefakte sofort oder nach einer bestimmten Zeit verworfen werden. Das setzt dem optimierten Einsatz durch die inkrementelle Analyse aus dem vorherigen Tipp Grenzen.
Pipelines und automatische Deployments kommen zum Einsatz, um schnelle Iterationen zu erreichen. Ein Durchlauf von PIT, egal wie optimiert, wird in fast allen Fällen eine zu lange Laufzeit der Pipeline zur Folge haben und den schnellen Iterationen entgegenstehen. Ein sinnvolleres Vorgehen sind daher zum Beispiel scheduled Pipelines, die einmal pro Nacht oder wöchentlich einen Mutation-Testing-Durchlauf als Qualitäts-Audit starten. Die Konfiguration von PIT ermöglicht die Festlegung von Thresholds, um Builds bei der Unterschreitung bestimmter Grenzwerte abzubrechen.
Tipp 6: Nutze PIT als Ergänzung!
Tools wie Jacoco im Java-Bereich bieten in pipeline-kompatibler Zeit sinnvolle Auswertungen zur Testabdeckung. Mit relativ wenig Aufwand brechen sie auch den Build ab, sollte sich die Testabdeckung verschlechtert haben, also ungetesteter Produktionscode dazugekommen sein. Wie oben beschrieben, lässt sich die reine Testabdeckung leicht anheben, ohne wirklich sinnvolle Tests zu schreiben. PIT kann diese Lücke füllen und die Qualität der hinzugekommenen Tests prüfen. Die Metrik der Mutation Coverage liefert hier einen guten Anhaltspunkt zu Testabdeckung und Testqualität in einer Zahl zusammengefasst.
Fazit – oder Tipp 7: Starte mit PIT!
Test-Driven-Development ist aus dem professionellen Arbeitsalltag in der Software-Entwicklung nicht mehr wegzudenken. Tools und Metriken unterstützen bei der konsequenten Umsetzung der Prinzipien des TDD. Mutation-Testing-Frameworks wie PIT bilden eine sinnvolle Ergänzung, um zu einer ganzheitlichen Betrachtung von Testabdeckung und Qualität der Tests zu gelangen. Durch die nahtlose Einbindung und sinnvolle Grundkonfiguration fallen die ersten Schritte mit PIT leicht.
Natürlich gibt es Mutation-Testing-Frameworks auch für andere Sprachen als Java. Ein guter Einstieg ist das "Awesome Mutation Testing"-Repository auf GitHub [7]. Neben einer gut recherchierten Tool-Übersicht verlinkt der Autor Material zu Grundlagen und weiterführenden Informationen rund um das Mutation-Testing.
Die Tools stehen bereit und warten auf den Einsatz in realen Projekten. Damit gibt es keine Ausreden mehr für schlechte Tests und für instabile Systeme. PIT und Mutation-Testing im Allgemeinen führen zu Unrecht ein Schattendasein. Sie haben einen Platz auf der Sonnenseite unserer Projekte verdient!
- Wikipedia: Testabdeckung
- Jacoco
- Pitest: FAQ
- Pitest: Overview
- Pitest: QuickStart
- Pitest: Incremental analysis
- Github: Awesome Mutation testing