Mutationstests mit PIT in Java

Unit-Tests gehören bei vielen Entwicklern zur täglichen Arbeit als Schutz vor Regressionen und als lebende Dokumentation. Aber wie lässt sich die Qualität der Tests überprüfen?

In Pocket speichern vorlesen Druckansicht 6 Kommentare lesen
Mutationstests mit PIT in Java
Lesezeit: 15 Min.
Von
  • Johannes Dienst
Inhaltsverzeichnis

Softwareentwicklung ohne Tests auf allen Ebenen ist heutzutage nicht mehr vorstellbar. Besonders die testgetriebene Entwicklung führt zu leicht testbarem Code. Den Mehraufwand für das Schreiben der Tests sparen Teams bei Refactorings wieder ein. Außerdem garantiert eine ausreichend große Testsuite den Schutz vor Regressionen.

Demnach stellt sich die Frage, wer die Qualität der Tests überprüft und sicherstellt, dass sie sinnvoll sind und tatsächlich Fehler aufdecken. Als klassische Methoden bieten sich die Paarprogrammierung oder Code Reviews an. Diese Methoden sind effizient, hängen jedoch von der Kompetenz der Entwickler ab. Schöner wäre es, eine automatisierte Überprüfung der eigenen Testsuite durchführen zu können.

Eine Testsuite, die effektiv Regressionen und Abweichungen von der Spezifikation entdecken soll, muss eine hohe Qualität aufweisen. Der Qualitätsbegriff ist dabei schwammig, da er meistens subjektiv ist und nicht auf Fakten beruht.

Codeüberdeckungsmaßen sollen die Effizienz einer Testsuite quantifizierbar machen und so die Qualität der Tests sicherstellen. In der einfachsten Variante testen sie, ob die Testsuite eine bestimmte Codezeile ausführt. Das Ergebnis ist eine Prozentangabe des von Tests ausgeführten Codes. Ob eine Prozentzahl als Qualitätsmaßstab geeignet ist, sei dahingestellt.

Darüber hinaus gibt es besser ausgefeilte Varianten der Codeüberdeckung. Unter anderem kann die Zweigüberdeckung messen, ob jeder Ausführungszweig der Codebasis mindestens einmal durchschritten wurde, was eine härtere Anforderung an die Testsuite stellt.

Schließlich gibt es noch die Bedingungsüberdeckung mit ihren populären Varianten MC/DC und MCC. Sie sind im Rahmen der Zertifizierungen DO-178B und DO-178C für Software der kritischsten Ebene gefordert und haben eine äußerst umfangreiche Testsuite zur Folge.

Überdeckungsmaße sind eine nützliche Angelegenheit, um blinde Flecken in der eigenen Testsuite zu erkennen. Auch marketingtechnisch ist eine hundertprozentige Überdeckung des Codes eine wirksame Aussage. Man kann sich sicher fühlen, dass die Tests das tun, was sie sollen: jede Zeile Code ausführen.

In Wahrheit ist eine hundertprozentige Codeüberdeckung aber kein Garant für Fehlerfreiheit. Vielmehr ist sie als Hinweis auf eine gewisse Sorgfalt beim Erstellen der Tests zu sehen. Mit hoher Wahrscheinlichkeit ist die Testsuite in der Lage, Regressionen zu finden. Die Prozentzahl der abgedeckten Regressionen bleibt jedoch im Dunkeln.

Die Qualität der Tests hängt von den verwendeten Assertions ab. Sind diese unzureichend formuliert, nützt die vollständige Überdeckung nichts. Als Beispiel sei die folgende Assertion aus einem JUnit-Testfall gegeben, bei der die Beschaffenheit eines von einer Methode zurückgegebenen Objekts Ziel des Tests ist:

@Test public void getActiveObjectFromDatabase {
Object o = database.getActiveObject(42);
assertNotNull(o);
}

Der Testfall ist sinnvoll, da die Methode nicht null zurückgeben soll. Ist die Methode einfach gehalten, kann er schon ausreichen, eine Zeilenüberdeckung von 100 % herzustellen. Wenn er jedoch der einzige Testfall für die Methode ist, hat die Testsuite einen blinden Fleck. Unter anderem fehlt eine Prüfung darauf, ob das Objekt den Vorstellungen entspricht, die es erfüllen soll.

Beim Betrachten der Methode werden dem Objekt noch Zusatzinformationen mitgegeben, die der Test nicht überprüft:

public Object getActiveObject(int i) {
Object o = this.retrieveObject(i);
o.setState("active");
o.setDirty(true);
return 0;
}

Der Test deckt nicht auf, ob die Zuweisungen verschwinden oder sich ändern. Eine Regression ist somit unbemerkt in die Codebasis gewandert.

Robert C. Martin hat den Umstand treffend ausgedrückt: "Nimm alle Assertions aus deinen Tests, und deine Überdeckung bleibt unverändert!" (freie Übersetzung aus dem Blog)

Paarprogrammierung, testgetriebene Entwicklung und Code Reviews können zwar helfen, die blinden Flecke zu beseitigen, sie sind aber nicht automatisiert und daher nicht erschöpfend ausgelegt. Es bleibt ein gewisser Prozentsatz von unzureichenden beziehungsweise fehlenden Tests bestehen.

An dieser Stelle schaffen Mutationstests Abhilfe. Bereits 1971 von Richard Lipton vorgeschlagen, erlebt es in den letzten Jahren eine Renaissance in diversen Programmiersprachen. Dabei hilft die immer größere Rechenleistung von Mehrkernprozessoren, mit der sich diese Art der Analyse praktikabel umsetzen lässt. Zeitgemäße Werkzeuge nutzen die Parallelisierung, um den Durchsatz erheblich zu steigern.

Mutationstests basieren auf zwei Annahmen: Die erste ist, dass Programmierer kompetent sind und keine groben Fehler machen, die zu offensichtlichen Bugs führen. Da die Disziplin des Programmierens anspruchsvoll ist und viel Code entsteht, ist es aber wahrscheinlich, dass kleine Ungenauigkeiten entstehen, die zunächst nicht auffallen. Die zweite Annahme ist der sogenannte Kopplungseffekt. Gepaart mit der ersten Annahme, dass Fehler unvermeidlich entstehen, führt die Anhäufung kleiner Fehler schließlich doch zu einem Systemfehler.

Mutationstests machen sich diese Annahmen zunutze, indem es sogenannte Mutanten erzeugt, die jeweils genau eine kleine Änderung enthalten: die Ungenauigkeit beziehungsweise Regression. In Abbildung 1 sind die Mutanten mit M1, M2 und so fort ausgezeichnet. Anschließend erfolgt die Ausführung der Testsuite auf jedem Mutanten. Schlägt mindestens ein Test fehl, ist die Suite in der Lage, die künstlich eingebaute Regression zu finden. Im Jargon des Mutationstestens spricht man vom Töten der Mutante. Ansonsten überlebt der Mutant und damit die Regression. Je mehr Mutanten getötet werden, desto sicherer schützt die Testsuite vor Regressionen.

Vorgehensweise beim Testen von Mutationen (Abb. 1)

Mutationstests stellen sicher, dass die Tests bei der Überdeckung einer Codezeile in der Lage sind, Abweichungen zu entdecken. Es überprüft also, ob der Test für eine überdeckte Codezeile sinnvoll verläuft. Der Autor von PIT, einem Tool für Java, bezeichnet Mutationstests daher als Goldstandard.

Auch wenn es eine große Zahl unterschiedlicher Mutatoren gibt, beschränkt sich dieser Artikel auf einige Standard-Mutatoren von PIT, die als gutes Beispiel dienen. Weitere Informationen finden sich in der Übersicht bei PIT.

Die im Folgenden vorgestellten Mutatoren mögen trivial erscheinen. Unter Berücksichtigung der grundlegenden Annahmen von Richard Lipton sind sie jedoch sinnvoll. Gerade einfache Fehler und Unachtsamkeiten, die nicht auffallen, führen im Alltag zu unerwünschtem Systemverhalten.

Bedingungen wie <, <=, > und >= sind das Ziel des Bedingungsgrenzen-Mutator. Er ändert die Grenzen wie in folgender Tabelle gezeigt:

Original Mutation
< <=
<= <
> >=
>= >

Aus dem Code

if (a < b) { }

wird im mutierten Zustand

if (a <= b) { }

Der mathematische Mutator ist sehr umfangreich. Er sucht sich alle mathematischen Ausdrücke und mutiert sie systematisch zu ihrem jeweiligen Gegenteil. Beispielsweise wird aus einer Multiplikation eine Division und umgekehrt. Das kann folgendermaßen aussehen:

int x = y * z;

wird zu

int x = x / z;

Der Rückgabewert-Mutator verändert die Rückgabewerte in das Gegenteil und zwar auf eine sichere Weise, sodass bei float und double kein NaN (Not a Number) zurückgegeben wird.

Rückgabetyp Mutation
boolean true wird zu false und false wird zu true
int, byte, short 0 wird zu 1, alles Andere zu 0
long x wird um 1 erhöht zu x+1
float, double X wird zu -(x+1.0), wenn x eine Zahl ist, NAN wird durch 0 ersetzt
Object Nicht null wird durch null ersetzt. Falls die unmutierte Methode null zurückgibt, wird eine java.lang.RuntimeException geworfen

Die nicht mutierte Version von

public boolean isEmpty() {
return this.myList.size == 0;
}

wird zu

public boolean isEmpty() {
return (this.myList.size == 0) ? false : true ;
}

Der letzte Mutator ist nützlich, um Nebeneffekte zu finden, die durch Tests abgedeckt sind, deren Veränderung die Tests aber nicht bemerken. Dafür entfernt der void-Methoden-Mutator die Aufrufe zu Methoden mit dem Modifizierer void. In ihnen verstecken sich oft Seiteneffekte wie das Speichern von Ergebnissen in eine Datenbank, die bei Tests gerne vergessen werden.

Bei folgendem Codeabschnitt:

public void saveToDatabase(Object o) { /* Save it */ }
public Object updateObject(Object o) {
o.setState("active");
saveToDatabase(o);
}

fällt in der mutierten Version der Nebeneffekt weg

public Object updateObject(Object o) {
o.setState("active");
}

Um Seiteneffekte zu testen, eignet sich ein Spy, ein Mock-Objekt, das die Aufrufe protokolliert. Mit ihm lässt sich überprüfen, ob eine Methode tatsächlich aufgerufen wurde oder auf irgendeine Art und Weise weggefallen ist – wie im obigen Beispiel. Zwar ist das Schreiben von Tests, die auf diese Weise arbeiten, nicht schwierig, aber im Alltagsgeschäft fällt ein Spy häufig unter den Tisch, da er mehr Aufwand verursacht und auf den ersten Blick nicht notwendig erscheint.

Das Mutationstesttool PIT ist für Java spezialisiert. Für andere Programmiersprachen existieren zahlreiche nützliche Tools wie Stryker, Humbug, Mutant und Mucheck.

PIT stand früher für Parallel Isolated Tests und trug damit die Parallelisierung im Namen. Der Autor des Tools hat es dann erweitert, um die spannendere Aufgabenstellung der Mutationstests abzudecken.

PIT hat gegenüber anderen Werkzeugen einige Vorteile: Der Macher entwickelt es aktiv weiter, und es ist im Vergleich zu früheren Generationen von Tools um ein Vielfaches schneller. Dadurch taugt PIT durchaus für den Einsatz in realen Projekten. Weitere Vorzüge sind die leichte Integrierbarkeit in Entwicklungsumgebungen und ein aktiv entwickeltes SonarQube-Plug-in. Außerdem lässt es sich umfangreich konfigurieren, und es unterstützt inkrementelle Analysen. Dadurch eignet es sich für den Einsaz zur Entwicklungszeit in der testgetriebenen Entwicklung.

Als kleine Demonstration dient die Überprüfung der Tests einer Implementierung von Quicksort, die dafür eine kleine Überarbeitung erfahren hat.

static List<Integer> sort(List<Integer> numbers) {
if (numbers.size() <= 1)
return numbers;

int pivot = numbers.size() / 2;
List<Integer> lesser = new ArrayList<>();
List<Integer> greater = new ArrayList<>();
int sameAsPivot = 0;

for (int number : numbers) {
if (number > numbers.get(pivot))
greater.add(number);
else if (number < numbers.get(pivot))
lesser.add(number);
else
sameAsPivot++;
}

lesser = sort(lesser);
for (int i = 0; i < sameAsPivot; i++)
lesser.add(numbers.get(pivot));

greater = sort(greater);
List<Integer> sorted = new ArrayList<>();
for (int number : lesser)
sorted.add(number);
for (int number: greater)
sorted.add(number);

return sorted;
}

Eine auf den ersten Blick vernünftige Testsuite dafür könnte in etwa folgendermaßen aussehen:

public class Quicksort_Test {
@Test public void emptyList() {
assertEquals(true,
sort(Collections.<Integer>emptyList()).
isEmpty());
}

@Test public void oneList() {
assertEquals(false,
sort(Stream.of(42).
collect(Collectors.toList())).isEmpty());
}

@Test public void twoList() {
assertEquals(new Integer(1),
sort(Stream.of(2, 3, 1, 8).
collect(Collectors.toList())).get(0));
}
}

Mit dieser Vorbereitung kann PIT nach einem Durchlauf einen Report erstellen, der in Abbildung 2 zu sehen ist. Aus der Übersicht geht hervor, dass die Suite eine Klasse getestet hat, in der eine Zeilenüberdeckung von 96% vorliegt, und dabei 11 von 16 Mutanten aufgespürt hat. Das bedeutet, dass sie fünf mögliche Regressionen nicht finden kann. Die Übersicht eignet sich dazu, ein generelles Gefühl für den Zustand der Testsuite zu erhalten.

Übersicht des PIT-Reports zum Mutationstest (Abb. 2)

Interessanter ist der Detailreport zur Klasse Quicksort, der ausschnittsweise in Abbildung 3 zu sehen ist. Hellgrün hinterlegte Zeilen bedeuten, dass die Testsuite sie überdeckt. Hellrot zeigt ein Fehlen der Zeilenüberdeckung an. Der Report gibt die möglichen Mutationen für unüberdeckte Zeilen zwar an, aber PIT verzichtet auf das Erstellen der Mutanten, da die Testsuite sie nie finden kann. Damit verhindert das Tool unnötige Arbeit und verringert gleichzeitig die Durchlaufzeit. Dunkelgrüne Zeilen bedeuten, dass von ihnen eine oder mehrere Mutanten erstellt und alle von der Testsuite getötet wurden. Dunkelrote Zeilen zeigen Mutationen auf, die der Testsuite verborgen geblieben sind.

Detailreport über die Mutationsüberdeckung von Quicksort (Abb. 3)

Der Report gibt nicht nur Auskunft über das Einfügen von Mutationen. Ein Überfahren der Zahl auf der linken Seite mit der Maus verrät zudem, was PIT verändert hat. Die Zahl gibt an, wie viele Mutationen in der jeweiligen Zeile möglich sind. In Abbildung 4 zeigt der Mouseover, dass von der for-Schleife drei Mutanten existieren. Die Mutationen eins und drei führen zu Regressionen. Leider wird von der Testsuite nur die dritte gefunden, bei der das Pivot-Element nicht in der Ergebnisliste auftaucht. Mutation eins führt zu einem doppelten Auftreten des Pivot-Elements in der Ergebnisliste und wird nicht getötet.

Ein Blick in die Testsuite zeigt, dass sie im dritten Test lediglich das erste Element überprüft. Das Erweitern des Test auf alle Elemente oder das zusätzliche Prüfen der Listenlänge tötet auch die dritte Mutation.

Der Report zeigt für jede Zeile an, welche Mutanten erstellt wurden und ob sie überlebt haben. TIMED_OUT steht für Mutanten, die Endlosschleifen erzeugen (Abb. 4).

Reale Projekte lassen sich nicht so leicht einem Mutationstest unterziehen wie das überschaubare Quicksort-Beispiel. Schon bei mehreren tausend Zeilen Code und einer entsprechenden Testüberdeckung von über 80 % entsteht eine große Anzahl von erzeugten Mutanten. In einem realen Projekt des Autors mit einem Modul von etwa 3000 Zeilen Umfang und 100 % Testüberdeckung entstanden mehrere Hundert.

Alle Mutanten zu töten und damit eine perfekte Testsuite zu erreichen, ist nicht praktikabel. Es lohnt sich daher, den Fokus auf besonders kritische Stellen zu setzen. PIT bietet außerdem einige Konfigurationsmöglichkeiten an, um eine graduelle Einführung von Mutationstests in ein Projekt zu gewährleisten.

Die Mutationserstellung lässt sich unter anderem gezielt auf einzelne Packages oder Klassen beschränken, was die Ausführungsgeschwindigkeit deutlich erhöht. Das ist insbesondere zur Entwicklungszeit interessant. Außerdem können Tester die Anzahl der Mutationen pro Klasse begrenzen. In Kombination mit der Einschränkung der aktiven Mutatoren reduziert sich die Anzahl der zu tötenden Mutationen.

Für das Erzeugen und Testen aller Mutanten bei einem initialen Lauf von PIT bietet sich die inkrementelle Analyse an. PIT legt dazu eine Datei an, in der es die Ergebnisse und den Ablauf der Analyse speichert. Bei nachfolgenden Läufen mutiert und testet es nur Stellen, an denen sich entweder der Code oder die dazugehörigen Tests verändert haben. Die Ausführungsgeschwindigkeit erhöht sich dadurch deutlich im Vergleich zum initialen Lauf.

Teams, die auf testgetriebene Entwicklung setzen, können Mutationstests ohne erheblichen Zeitaufwand in Kombination mit der inkrementellen Analyse einsetzen. Die Feedbackschleife reduziert sich dadurch auf ein Minimum, und die Testsuite ist von Anfang an gegen Regressionen geschützt.

Mutationstests sind eine neue alte Technik zur Sicherung der Qualität von Tests. Es stellt sicher, dass jede abgedeckte Codezeile aussagekräftige Tests besitzt. Findet eine Testsuite viele der gezielt mit Mutanten in die Codebasis eingebauten kleinen Regressionen, ist sie vor diesen Fehlern sicher, die im Programmieralltag unweigerlich entstehen.

Durch die verfügbare Rechenleistung und Mehrkernprozessoren, die ein paralleles Abarbeiten von Mutationen erlauben, ist diese Art der Qualitätssicherung für Tests inzwischen attraktiv geworden. Moderne Tools wie PIT verkürzen die Analysedauer auf wenige Minuten bis Stunden – früher mussten Teams dafür mehrere Tage einplanen.

Wer eine hohe Testüberdeckung in Projekten aufzuweisen hat, sollte einen Blick auf Mutationstests werfen. Die Ergebnisse können überraschend sein und die Testsuite deutlich verbessern. Regressionen werden dadurch weniger wahrscheinlich, und die Fehlerrate sinkt.

Johannes Dienst
ist Clean Coder aus Leidenschaft. Seine Tätigkeitsschwerpunkte sind die Wartung und Gestaltung von serverseitigen Java- und JavaScript-basierten Applikationen. Twitter: @JohannesDienst.

(rme)