Mutation Testing mit Pitest

Codequalität und -korrektheit lassen sich unter anderem mit Hilfe von Unit-Tests sicherstellen. Aber nicht jeder Unit-Test ist sinnvoll und häufig schaffen es Bugs von Unit-Tests unerkannt zu bleiben. Wie lässt sich die Testqualität erhöhen, um Programmierfehler zuverlässiger und früher zu erkennen?

Wie einige andere Softwareproduzenten auch, verschreiben wir uns der testgetriebenen Entwicklung. Bei diesem Ansatz werden zuerst Unit-Tests und anschließend die eigentliche Logik implementiert. Erfreuliches Resultat dessen ist eine hohe Testabdeckung des Codes. Diese Metrik lässt allerdings nur eingeschränkt Rückschlüsse auf die Korrektheit und Qualität des zu testenden Programmcodes zu. In der Testabdeckung werden alle Codezeilen erfasst, die im Rahmen von Tests durchlaufen werden, nicht zwangsweise fehlerfreie Zeilen. Im Ergebnis bedeutet das, dass sich mit Hilfe der Testabdeckungsmetrik nur Zeilen finden lassen, die auf keinen Fall auf Korrektheit getestet werden.

Um das Extrembeispiel aufzuführen: Es ist in den meisten Fällen ohne weiteres möglich, Tests zu konstruieren, die jeden Programmpfad und jede Zeile Code abdecken, ohne eine einzige Bedingung zu prüfen. Trotz 100-prozentiger Coverage ist der Informationsgehalt über die Korrektheit des Codes gleich null.

Es stellt sich also die Frage, wie eine sinnvolle Testabdeckung durch Tests von hoher Qualität erreicht werden kann. Genauer: Wie können wir die Qualität von Unit-Tests messen? Um diese Frage zu beantworten, muss man sich mit den Gründen auseinandersetzen, warum man überhaupt Tests schreibt. Einer der Gründe – vermutlich der wichtigste – ist das Vermeiden von Programmierfehlern. Ein idealer Unit-Test stellt sicher, dass die zu testende Codeeinheit genau das tut, was sie soll. Nun lässt sich leider die Abwesenheit von Fehlern im Allgemeinen weder beweisen, noch durch Tests sicherstellen, allerdings können sie Entwickler dabei unterstützen, die wahrscheinlichsten Fehler zu vermeiden. Hierbei obliegt es dem Testautor dafür zu sorgen, dass die Tests den Code auf eben diese wahrscheinlichsten Fehler zu untersuchen. Versäumt er es, wichtige Fehlerquellen in Betracht zu ziehen, kann es trotz einer hohen Testabdeckung zu Fehlverhalten der Anwendung kommen. Was also tun?

Mutation testing

Ein Werkzeug, welches uns helfen kann die Qualität unserer Tests zu verbessern, ist das so genannte Mutation Testing. Beim Mutation Testing wird pro Testsuite-Durchlauf jeder Test nicht nur ein einziges Mal, sondern mehrfach ausgeführt. Vor jedem Testdurchlauf wird der zu testende Code entsprechend gegebener Regeln verändert oder mutiert. Schlägt der Test nach einer Änderung fehl, hat er seine Robustheit in diesem Falle unter Beweis gestellt. Bleibt er allerdings grün, kann dies ein Hinweis darauf sein, dass der Code den durch die Veränderung ins Leben gerufenen Fehlerfall nicht abdeckt.
Am Ende lässt sich also für jeden Unit-Test sehen, welche Veränderungen nicht von ihm erkannt wurden.
Diese Veränderungen werden als Mutationen (Mutations) bezeichnet. All jene Mutationen, die den Test fehlschlagen lassen, werden als abgetötet (killed) bezeichnet, da sie den Test nicht überlebt haben. Unter dem Begriff “Überlebende” (Survivors) versteht man Mutationen, die nicht durch einen Test erkannt werden – trotz ihrer Anwesenheit werden die Tests grün. Hierbei sei zu erwähnen, dass einen Test überlebende Mutationen nicht zwangsweise ein Problem darstellen; sie können aber sehr wohl auf eines hinweisen.

Jede Testsuite mehrfach zu durchlaufen und zwischen den Läufen auch noch den zu testenden Code zu mutieren, bedeutet einen erheblichen Aufwand, der manuell nicht sinnvoll zu leisten ist. Ohne Tools zur Automatisierung wären Mutationstests deshalb nicht praktikabel. Ein Werkzeug für JVM-basierte Programmiersprachen, welches die Mutationstests automatisiert, ist Pitest. In diesem Artikel werden wir die Nutzung und den Nutzen dieses Tools erläutern und beleuchten.

Beispiel

Anhand eines anschaulichen Beispiels werden wir die Sinnhaftigkeit von Mutationstests sowie die Einrichtung von Pitest mit Maven erläutern.

Nehmen wir an, es sei folgende Klasse zum Durchführen einfacher Rechnungen auf Integern in einem Maven-Projekt gegeben:

package de.triology.blog.pitest;

class Calculator {
	static int add(int a, int b) {
    		return a + b;
	}

	static int subtract(int a, int b) {
        	return a - b;
	}

	static int multiply(int a, int b) {
    		return a * b;
	}
}

Da testgetrieben entwickelt wurde, gibt es folgende Unit-Tests zu dieser Klasse:

package de.triology.blog.pitest;
import org.junit.Test;

import static com.google.common.truth.Truth.assertThat;

public class CalculatorTest {

	@Test
	public void add() {
    		assertThat(Calculator.add(2, 2)).isEqualTo(4);
	}

	@Test
	public void subtract() throws Exception {
    		assertThat(Calculator.subtract(3, 0)).isEqualTo(3);
	}

	@Test
	public void multiply() throws Exception {
    		assertThat(Calculator.multiply(5, 1)).isEqualTo(5);
	}
}

Die Tests bieten eine 100-prozentige Methodenabdeckung und werden beim Durchführen auch alle grün. Aber wie sieht es mit ihrer Robustheit gegenüber Mutationen aus? Um das herauszufinden, fügen wir dem Projekt das Pitest-Maven-Plugin hinzu. Dafür schreiben wir folgendes in die pom.xml:

	<build>
    	<plugins>
        	<plugin>
            	<groupId>org.pitest</groupId>
            	<artifactId>pitest-maven</artifactId>
            	<version>1.2.0</version>
        	</plugin>
    	</plugins>
	</build>

Nachdem wir das Plugin dem Projekt hinzugefügt haben, lässt es sich wie folgt aufrufen:

clean install org.pitest:pitest-maven:mutationCoverage

Nun dauert es einen Augenblick, bis das Projekt gebaut und die Mutationstests vorgenommen wurden. Das Pitest-Plugin erstellt im target-Verzeichnis des Projekts einen Ordner mit dem Namen pit-reports – in diesem befinden sich die Ergebnisse der Mutationstests. Öffnen wir nun die im Unterverzeichnis liegende HTML-Datei und folgen den Links bis zur zu testenden Klasse, bekommen wir alle überlebenden und getöteten Mutationen angezeigt:

Wie auf dem Bild zu sehen ist, gab es sechs Mutationen, von denen zwei die Testsuite überlebt haben. In beiden Fällen handelt es sich um Mutationen auf mathematischen Operationen; einmal wurde ein Minus durch ein Plus ersetzt, ein anderes Mal ein Sternchen als Multiplikationsoperator durch einen Schrägstrich als Divisionsoperator.

Die zu testende Klasse ist augenscheinlich leicht zu erkennen fehlerfrei – trotzdem gibt uns das Ergebnis des Mutationstests sinnvolle Hinweise: Die Klasse könnte Fehler enthalten, aber unsere Testsuite wäre trotzdem Grün. Im gegebenen Fall: Die Tests lassen es zu, dass addiert statt subtrahiert und dividiert statt multipliziert wird, ohne dass sie fehlschlagen.

Natürlich sind die Testdaten zu Anschauungszwecken genau so gewählt, dass es zu überlebenden Mutationen kommt. Ändern wir die Unit-Tests wie folgt, kommt Pitest zu einem anderen Ergebnis:

public class CalculatorTest {

	@Test
	public void add() {
    	assertThat(Calculator.add(2, 2)).isEqualTo(4);
	}

	@Test
	public void subtract() throws Exception {
    		assertThat(Calculator.subtract(3, 1)).isEqualTo(2);
	}

	@Test
	public void multiply() throws Exception {
    		assertThat(Calculator.multiply(6, 3)).isEqualTo(18);
	}
}

Die Tests werden nach wie vor Grün, allerdings werden jetzt zusätzlich auch alle Mutationen abgetötet:

Mutatoren

Die beiden überlebenden Mutationen aus dem Beispiel entstanden durch ersetzen eines mathematischen Operators durch sein Inverses, das heißt aus Addition wurde Subtraktion und aus Multiplikation wurde Division. Die Regeln nach denen diese Ersetzungen stattfinden, werden in sogenannten Mutatoren (Mutators) hinterlegt. Pitest selbst liefert einige Mutatoren aus, unter anderem zum Beispiel den oben verwendeten MATH-Mutator. Dieser Mutator ersetzt nicht nur Subtraktions- und Multiplikationsoperatoren, sondern insgesamt folgende:

Andere Mutatoren ersetzen jedes Inkrement durch ein Dekrement, oder lassen If-Bedingungen immer true (oder false) sein. Eine vollständige Dokumentation der von Pitest mitgelieferten Mutatoren, findet sich in der entsprechenden Dokumentation.

Die durch Pitest automatisch out of the box durchgeführte Kombination von Mutatoren ermöglicht die Identifikation von Problemen und Uneindeutigkeiten innerhalb der Testsuite bei geringem zeitlichen Mehraufwand für den Entwickler. Wie die meisten guten Dinge haben auch Mutationstests ihren Preis.

Nachteile von Mutationstests

Zwar müssen Mutationstests mit Pitest nicht vom Entwickler geschrieben, aber an irgendeinem Zeitpunkt ausgeführt werden. Die Laufzeit der Mutationstests ist in den allermeisten Fällen um einiges größer, als das reine Ausführen der Unit-Tests. So werden für jedes Stück Code, welches durch einen Unit-Test getestet wird, eine oder mehrere Mutationen generiert, für die jeweils der Unit-Test ausgeführt wird. Selbst bei Projekten mit wenigen tausend Zeilen Code mit hoher Testabdeckung kann das Ausführen der Mutationstests so einige Minuten dauern. Und das trotz der Optimierungen, die Pitest vornimmt. So werden beispielsweise für eine Mutation nicht zwangsweise alle Tests durchlaufen, sondern nur die, die auch eine Chance haben, diese Mutation zu entdecken. Sobald ein Test eine Mutation abfängt, werden für diese dann auch keine weiteren Tests mehr ausgeführt.

Auch lässt sich Pitest in Kombination mit dem Maven-SCM-Plugin so konfigurieren, dass nur neu hinzugefügter Code mutationsgetestet wird. Im Rahmen einer CI-Pipeline hat man so beispielsweise die Möglichkeit, Mutationstests immer im Nightly Build und dann nur auf neu hinzugekommenen Code durchzuführen. Am Ende dieser Pipeline kann dann ein SonarQube stehen, in das die Pitest-Ergebnisse importiert werden. Wie das funktioniert, erklären wir in einer Fortsetzung dieses Blogeintrags.

 

Ähnlich wie von einer hohen Testabdeckung kann man sich als Softwareentwickler auch von einer hohen Mutationserkennungsrate zu einem Overengineering der Unit-tests hinreißen lassen. Und obwohl es sicherlich gut ist, wenn keine Mutation die Tests überlebt, muss im Einzelfall entschieden werden, welche Überlebensrate Sinn ergibt, beziehungsweise welche Mutationen man ohne schlechtes Gewissen überleben lassen möchte. Momentan bietet Pitest keine Möglichkeit, bestimmte Überlebende zu ignorieren. Entschließt man sich also bewusst dazu eine Mutation nicht abzutöten, tauchen diese trotzdem immer im Pitest-Report auf.

Fazit

Mutationstests können mit geringem Aufwand helfen, schwache Unit-Tests zu erkennen, zu verbessern und somit zu einer höheren Softwarequalität beizutragen. Sie sind jedoch kein Allheilmittel – die Einführung von Mutationstests wird die Qualität eines Produktes nicht plötzlich schlagartig steigen lassen. Allerdings bieten Mutationstests einen gangbaren Weg, das Vertrauen des Entwicklers in die eigenen Unit Tests zu erhöhen – ähnlich wie Unit-Tests das Vertrauen in den Code erhöhen können. Ob und in welchem Umfang Mutationstests eingesetzt werden, muss von Fall zu Fall entschieden werden. Einen goldenen Weg gibt es dafür nicht.

Diesen Beitrag teilen

Philipp Czora
Software Development
Philipp umgibt sich am liebsten mit Menschen, von denen er etwas lernen kann und versucht in der Softwareentwicklung die perfekte Mischung aus Pragmatismus und Perfektionismus zu finden.