Mutations Testing
JUnit ist im Bereich des TDD für den Java Entwickler ein bekanntest Werkzeug. Hier hat sich auch durchgesetzt, dass man die Testabdeckung (CodeCoverage) messen kann. Hierbei unterscheidet man die Abdeckung auf Klassen-, Methoden- und Zeilenebene. Ziel ist es, die Testabdeckung auch Zeilenebene so hoch wie möglich, nicht aber höher als nötig zu bekommen. Aber was genau bedeutet das? Eine Testabdeckung von ca. 75% auf Zeilenebene ist sehr gut und kann einem schon als Grundlage dienen. Aber wie aussagekräftig ist diese Zahl?
Hier greift der Begriff Mutation Testing. Der Begriff selbst wurde in den 70´er Jahren das erstemal in der Literatur erwähnt. Das Prinzip ist recht einfach. Es werden von einem Stück Quelltext Mutationen erzeugt. Wenn man diese gegen die bestehenden Tests laufen lässt, sollte mindestens ein Test fehlschlagen. Man spricht in dem Moment davon, dass die Mutation nicht überlebt hat. Reagiert allerdings kein einziger Test mit einem Fehlschlag, so hat die Mutation überlebt. Es ist laut der Tests also irrelevant, welche Version vorhanden ist. Das ist natürlich nicht das Ziel der bestehenden Tests gewesen. Ausserdem besteht eine Unschärfe im System, die sich auf Dauer in Kombination mit anderen Unschärfen zu unbestimmbaren Fehlern ausweiten kann. Das Ziel muss also sein, nicht nur eine hohe Testabdeckung zu erzeugen, sondern auch eine möglichst robuste. Aber wie genau kann eine Maschine einen dabei unterstützen?
Beginnen wir mit kleinen Modifikationen. Es kann z.B. ein logisches UND zu einem logischen ODER verändert werden. Oder man kann Grenzen aufweichen, bzw. härter formulieren. Das kann durch ein Austauschen von < mit einem <= erreicht werden. Alleine Modifikationen dieser Art in einem größeren Projekt durchzuführen erfordert viele Mutationen. Das ist eine Aufgabe für eine Maschine. Es gibt nun verschiedenste Modifikationen, und dann auch natürlich die Kombinationen von Modifikationen. Das kann man beliebig weit führen. Es ist offensichtlich, dass dieses schnell zu exponentiellem Wachstum der Anzahl der Mutationen führt. Diese Mutationen von Hand einzubauen macht wenig Sinn. Jedoch gibt es an dieser Stelle Werkzeuge die einem genau dieses abnehmen.
Genug der Theorie, nehmen wir uns im folgenden diese Implementierung eines Services vor.
public class Service {
public int add(int a, int b){
if(a<2){
return (a+b) * -1;
} else {
return a+b;
}
}
}
Hier ist eine Methode add(a,b) die zwei Integerwerte addieren soll. Allerdings unterliegt diese Implementierung einigen Besonderheiten. (Ich unterstelle hier einen fachlichen Hintergrund ;-) )
Beginnen wir nun klassisch mit der Erstellung der Testfälle und erzeugen eine Klasse ServiceTest.
@Test
public void testAdd001() throws Exception {
final Service service = new Service();
final int add = service.add(0, 0);
Assertions.assertThat(add).isEqualTo(0);
}
//increasing code coverage -> 100 percent
@Test
public void testAdd004() throws Exception {
final Service service = new Service();
final int add = service.add(3, 0);
Assertions.assertThat(add)
.isPositive()
.isEqualTo(3);
}
Ich verwende hier übrigens AssertJ um eine fluent-API zur Definition der Testfälle zur verfügung zu haben. Wenn man nun die Testabdeckung berechnen lässt, hat man eine 100% Abdeckung auf Zeilenebene erhalten. Aber sind diese Tests gut? Was genau definiert das Verständnis von gut?
Wir sind uns einig, das es sicherlich fachlich bessere Tests geben wird. Aber auch auf rein technischer Ebene gibt es messbar bessere Tests. Und hier kommt nun das Mutations Testing zu Einsatz.
www.pitest.org
Eines dieser Werkzeuge um ein Mutation Testing durchzuführen ist pitest. Hierbei handelt es sich um ein Open-Source Werkzeug. Im Prinzip funktioniert es wie folgt.
Die compilierten Test-Klassen werden genommen und in gezielt modifizierter Form erneut gespeichert. Dann werden die Tests alle nochmal durchgeführt und das Ergebnis mit dem Originalergebnis verglichen. Hieraus ergibt sich eine Liste von Modifikationen die abgefangen werden und eine Liste von Modifikationen die nicht detektiert worden sind. Hierbei wird einem in dem erzeugten Report die Modifikation und Quelltextstelle mitgeteilt.
In unserem Beispiel kommt dann folgendes heraus.
Der Report gibt an, an welcher Zeile Quelltext z.B. Zeile 8, wieviele Mutationen vorgenommen worden sind. In diesem Fall sind es 5 verschiedene. Davon wurde Mutation 3 und 5 durch die bestehenden Testfälle robust identifiziert. Es sind also Testfälle fehlgeschlagen. Die anderen Mutationen sind nicht identifiziert worden. Nehmen wir uns nun die beiden Fälle 1 und zwei vor. Erweitern wir um folgenden Test, werden die Fälle 1 und 2 ebenfalls identifiziert.
@Test
public void testAdd003() throws Exception {
final Service service = new Service();
final int add = service.add(2, 0);
Assertions.assertThat(add)
.isPositive()
.isEqualTo(2);
}
Das Ergebnis ist dann:
Was genau ist hier passiert? Der vorherige Testfall hat nicht einen Wechsel der Grenze mit berücksichtigt. Deswegen konnte im Sourcecode die 2 auf eine 3 geändert werden, ohne das es Auswirkungen gegeben hat. Ebenfalls wurde auch die Modifikation von <
auf <=
damit abgefangen. Beides Dinge die einem im realen Leben sehr viel Zeit kosten können.
Nun gehen wir zu dem Punkt 4 in Zeile 8 und dem Punkt 1 in Zeile 9.
// added step 2
@Test
public void testAdd002() throws Exception {
final Service service = new Service();
final int add = service.add(1, 0);
Assertions.assertThat(add)
.isNegative()
.isEqualTo(-1);
}
Mit diesem Test sind wir unter der Schranke <2
aber so, das die Multiplikation mit -1
Auswirkungen hat. Das Ergebnis ist dann:
Der nächste Fall der eliminiert wird, ist Zeile 9 Punkt 2. Hier kann man einfach durch setzen des zweiten Parameters ungleich 0 dafür sorgen das es identifiziert werden kann.
// added step 3
@Test
public void testAdd005() throws Exception {
final Service service = new Service();
final int add = service.add(1, 1);
Assertions.assertThat(add)
.isNegative()
.isEqualTo(-2);
}
Zum Ende hin haben wir nun noch zwei Fälle, die nicht identifiziert werden. Hier stellt sich aber raus, dass es auch Fälle geben kann, die nicht identifiziert werden können. Hier ist es Zeile 9 Punkt 3. Das ist jedoch auch ok, da eine Division durch -1 das selbe Ergebnis liefert wie eine Multiplikation mit -1.
Den Punkt 1 Zeile 11 allerdings lässt sich leicht erfassen.
// added step 4
@Test
public void testAdd006() throws Exception {
final Service service = new Service();
final int add = service.add(2, 2);
Assertions.assertThat(add)
.isPositive()
.isEqualTo(4);
}
Damit ist das Ergebnis sehr gut gegen versehntliche Modifikationen geschützt.
notwendige Vorbereitungen
Um pitest zu verwenden muss man bei Maven-Projekten lediglich das entsprechende Plugin aktivieren und nach dem ausführen des Tests das Ziel pitest:mutationCoverage
starten.
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.1.5</version>
<configuration>
<mutators>
<mutator>ALL</mutator>
</mutators>
<targetClasses>
<param>org.rapidpm.test.pitest.*</param>
</targetClasses>
<targetTests>
<param>org.rapidpm.test.pitest.*</param>
</targetTests>
</configuration>
</plugin>