Mutation Testing mit PIT

Mutation Testing: Attack of the Java Mutants

Kevin Wittek

©Shutterstock / delcarmat

Im gleichen Maß, in dem das Schreiben von Tests immer mehr in den Aufgabenbereich von Entwicklern fällt, müssen wir uns vermehrt die Frage stellen, wie wir die Qualität unserer Testsuite messen und bewerten können. Mutation Testing ist in diesem Kontext ein interessantes Tool, das wir zur Entwicklungszeit einsetzen können, um unsere Tests zu verbessern, während wir sie schreiben.

Für viele von uns sind Tests ein ständiger Begleiter im Entwickleralltag und für manche womöglich ein zuverlässiger Partner, der es uns erlaubt, die Kontrolle über unseren Produktionscode zu behalten. So ermöglicht es uns eine gute Testsuite, unseren Code kontinuierlich zu erweitern und einem Refactoring zu unterziehen, ohne dass wir uns Sorgen machen müssen, bestehende Funktionalitäten dabei zunichte zu machen.

Aber wie kann ich beim Refactoring meines Testcodes Sicherheit erhalten? Woher weiß ich, dass ich einer Testsuite trauen kann, wenn ich in ein bestehendes Projekt komme? Wie kann ich sicherstellen, dass ich effektive Tests schreibe? Und woher weiß ich, dass mein Team effektive Tests schreibt?

Zusammenfassend stellt sich uns also die Frage: Wie kann ich die Qualität meiner Testsuite beurteilen? Hier wird als Antwort gerne auf Code Coverage verwiesen (vielleicht auch, weil es sich als Metrik so managerfreundlich in Tools wie SonarQube visualisieren lässt). Es gibt verschiedene Code-Coverage-Kriterien, üblich sind zum Beispiel Line-, Branch- oder Statement-Coverage. Doch diese Coverage sagt letztlich nur aus, welche Teile unseres Codes ausgeführt wurden, aber nicht, welche Teile getestet werden. Code ausführen ist nicht das gleiche wie Code testen. Entsprechend kann Code Coverage uns nur sagen, welcher Code nicht getestet wurde.

Rise of the Mutants

Bereits 1971 veröffentlichte der Student Richard Lipton, bekannt durch seine Pionierarbeit im Bereich des DNA-Computing, das Paper „Fault Diagnosis of Computer Programs“, in dem er erstmalig die Idee äußert, bewusst Bugs in eine zu testende Codebasis einzuführen, um die existierende Testsuite daraufhin zu bewerten, ob sie diesen Bug findet. Da Lipton dieses Paper während seines Studiums lediglich im Rahmen einer Seminararbeit veröffentlicht hat, ist es sehr schwer, diese Quelle ausfindig zu machen, wenngleich das Paper in vielen anderen akademischen Papers referenziert wird.

Glücklicherweise veröffentlichte Lipton zusammen mit DeMillo und Sayward im IEEE Computer Journal 1978 das Paper „Hints on Test Data Selection: Help for the Practicing Programmer“. Unter Berücksichtigung der Coupling-Effect-Hypothese präsentieren Lipton et al. in diesem eine Methode, um festzustellen, ob die verwendeten Testdaten ein Programm hinreichend getestet haben (wir können in diesem Fall Testdaten mit Unit-Tests gleichsetzen). Diese Methode wird in diesem Paper als Program Mutation bezeichnet. Der Coupling Effect besagt, dass Testdaten (Tests), die alle Programme abweichend von einem korrekten Programm nur durch einfache Fehler unterscheiden können, so empfindlich sind, dass sie auch komplexere Fehler implizit unterscheiden. Das Vorgehen ist bei der im Paper vorgestellten Methode wie folgt:

  1. Ein Programm P wird mit einer Menge Testdaten T, deren Eignung festgestellt werden soll, von einem Mutation System ausgeführt.
    1. Wenn hierbei Fehler auftreten, ist P fehlerhaft -> Abbruch
    2. Wenn hierbei keine Fehler auftreten, könnte P ebenfalls fehlerhaft sein, falls der Umfang von T ungenügend ist.
  2. Das Mutation System erstellt eine Reihe von Mutationen von P, die sich von P durch einzelne, einfache Fehler unterscheiden. Diese bezeichnen wir als P1, P2, …, Pk. So wird z. B. der Ausdruck a == b zu a >= b mutiert. Wenn wir nun die mutierten Programme mit den Testdaten ausführen, können zwei verschiedene Fälle eintreten:
    1. Pi verhält sich anders als P, erzeugt also einen Fehler basierend auf den Testdaten.
    2. Pi verhält sich wie P auf Basis der Testdaten.
  3. Für jeden Mutanten, für den der Fall (a) eingetreten ist, sagen wir, der Mutant wurde „getötet“, wir hatten also Testdaten, die diesen Fehler feststellen konnten. Im Fall (b) sagen wir, der Mutant „lebt“. Das kann zwei verschiedene Gründe haben:
    1. Unsere Testdaten sind ungenügend um den durch Pi eingeführten Defekt zu identifizieren (das ist eine erstrebenswerte Erkenntnis).
    2. P und Pi sind äquivalente Programme. Wir bezeichnen Pi in diesem Fall als einen Equivalent Mutant, und der kann unmöglich durch Testdaten getötet werden, weil sich das Programm trotz der Mutation verhält wie gewünscht. Diese Mutanten sind händisch zu identifizieren und können ignoriert werden.

Bereits in Liptons Paper wird erwähnt, dass dieser Prozess vor allem während des Entwicklungsprozesses dabei helfen soll, gute Testdaten zu erzeugen und einfach Fehler zu finden. Es geht also beim Mutation Testing nicht in erster Linie darum, Metriken in einem nachgelagerten QS-Prozess zu erzeugen.

Mutation Testing goes Open Source

Obgleich die theoretischen Grundlagen für Mutation Testing nicht neu sind, dauerte es viele Jahre, bis Mutation Testing schließlich in Teilen Einzug in den Programmieralltag hielt. Einer der prominenteren Open-Source-Vertreter im Java-Umfeld ist vermutlich das im Jahr 2000 veröffentlichte Projekt Jester. Zu Jester finden wir ein interessantes Zitat von Kent Beck: „Why just think your tests are good when you can know for sure? Sometimes Jester tells me my tests are airtight, but sometimes the changes it finds come as a bolt out of the blue. Highly recommended.“

Man könnte nun also meinen, dass ein Open-Source-Tool, das sogar von Kent Beck empfohlen wird, auf eine breite Akzeptanz in Entwicklerkreisen trifft. Das war allerdings augenscheinlich nicht der Fall, und auch wenn es schwer ist, konkrete Gründe zu benennen, so kann man zumindest einen großen Nachteil von Jester (und anderen Mutation-Testing-Tools der entsprechenden Epoche) identifizieren: Mutation Testing war extrem langsam und ressourcenhungrig. Klassische Mutation-Testing-Systeme kompilieren den Code für jeden Mutant und führen die gesamte Testsuite für jeden Mutant aus. Das sorgt dafür, dass Mutation Testing, vor allem in Hinsicht auf schnelles Feedback innerhalb des Entwicklungsprozesses, unpraktisch wird. Mit dieser Erkenntnis könnten wir den Artikel an dieser Stelle enden lassen und Mutation Testing wieder in die Academia-Schublade verbannen, gäbe es nicht mittlerweile Mutation-Testing-Systeme, die eine Lösung für diese Problematik gefunden haben. Ein prominenter Vertreter in der Java-Welt ist PIT.

PIT

PIT wurde 2010 von Henri Coles unter der Apache License veröffentlicht und stand ursprünglich für „Parallel Isolated Test“. Der initiale Anwendungsfall war die parallelisierte Ausführung von JUnit-Tests in separaten Class Loadern. Das diente letztlich aber als Grundlage, um aus PIT ein Mutation-Testing-System zu machen. PIT kann als Industriestandard für Mutation Testing im Java-Kontext angesehen werden und findet Verwendung in solch prominenten Projekten wie dem Large Hadron Collider am Europäischen Kernforschungszentrum CERN. Aber was macht PIT anders als Jester, um praktikabler zu werden? Dazu implementiert PIT eine Reihe sinnvoller Optimierungen gegenüber naiven Mutation-Testing-Ansätzen:

  • Tests werden parallelisiert ausgeführt. Da Unit-Tests in der Regel CPU-bound sind, erlaubt uns aktuelle Multi-Core-Prozessorarchitektur ein hohes Maß an Parallelität.
  • Mutants werden nicht kompiliert, sondern durch Bytecodemanipulation erzeugt.
  • Tests werden Anhand der Laufzeit priorisiert, sodass billige Tests zuerst laufen. Sobald ein Test fehlschlägt, kann der aktuelle Lauf abgebrochen werden, da der Mutant in dem Fall getötet wurde.
  • Mithilfe von Code Coverage wird eine Zuordnung zwischen Tests und Codezeilen erzeugt. Somit müssen nur die Tests gegen einen Mutant ausgeführten werden, die eine mutierte Codezeile ausführen.

All diese Faktoren zusammengenommen erlauben es, PIT als schnelles Feedback während der Entwicklung einzusetzen, z. B. im Kontext eines TDD-Zyklus. Die Integration in ein bestehendes Projekt ist denkbar einfach, und es gibt Plug-ins für alle gängigen Java-Build-Systeme wie Maven, Ant oder Gradle (darüber hinaus existieren außerdem Plug-ins für gängige IDEs wie z. B. Eclipse oder IntelliJ). In Listing 1 ist die beispielhafte Konfiguration als Maven-Build-Plug-in zu sehen.

<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.4.3</version>
</plugin>

Durch dieses Plug-in wird das Maven Goal mutationCoverage hinzugefügt, das sich folgendermaßen ausführen lässt:

mvn org.pitest:pitest-maven:mutationCoverage

Dieses Goal führt die Mutation-Tests aus, gibt eine Zusammenfassung über erzeugte, getötete und überlebende Mutants aus und erzeugt anschließend einen HTML-Report. Das Maven Goal ist umfangreich parametrisierbar, und es kann für einen schnelleren Feedbackzyklus sinnvoll sein, Mutation-Tests nur für einzelne Klassen auszuführen:

mvn org.pitest:pitest-maven:mutationCoverage -dtargetTests=my.package.Foo*Test -dtargetClasses=my.package.Foo*

PIT funktioniert out of the box mit JUnit4-Tests. Verwendet man JUnit5, muss ein zusätzliches Plug-in konfiguriert werden (Listing 2).

<plugin>
  <groupId>org.pitest</groupId>
  <artifactId>pitest-maven</artifactId>
  <version>1.4.3</version>
  <dependencies>
    <dependency>
      <groupId>org.pitest</groupId>
      <artifactId>pitest-junit5-plugin</artifactId>
      <version>0.7</version>
    </dependency>
  </dependencies>
</plugin>

Es ist relativ schwer, mit künstlichen Beispielen ein sinnvolles Verständnis für Mutation Testing zu vermitteln, und reale Open-Source-Projekte würden als Beispiel den Rahmen und die Verständlichkeit dieses Artikels sprengen. Wir werden uns daher trotz allem ein relativ abstraktes Beispiel anschauen (das mit freundlicher Genehmigung aus Henry Coles Javazone Talk übernommen wurde. Es ist aber zu empfehlen, PIT einfach einmal in einem eigenen Projekt einzusetzen, um ein echtes Gefühl für Mutation Testing zu bekommen.

Angenommen, wir haben den in Listing 3 dargestellten, korrekten Produktionscode. Würden wir nun Code Coverage als unsere einzige Metrik nehmen, anhand derer wir die Qualität unserer Testsuite bewerten, könnten wir die Testsuite aus Listing 4 nehmen und 100 % Code Coverage erreichen.

public class Counter {

  private int count;

  public void count(int i) {
    if (i >= 10) {
      count++;
    }

  }

  public int getCount() {
    return count;
  }
}
class CounterCodeCoverageTest {

  @Test
  void bossSaysMustHaveCodeCoverage() {
    Counter c = new Counter();

    c.count(0);
    c.count(9);
    c.count(11);

    c.getCount();
  }

}

Hier wird sehr schön deutlich, dass Code Coverage keinerlei Rückschlüsse auf getestete Codezeilen zulässt. Nun wollen wir allerdings annehmen, dass Entwickler in der Regel versuchen, sinnvolle Tests zu schreiben. Diese Tests könnten für unser Beispiel aussehen wie in Listing 5 dargestellt.

class CounterGoodFaithTest {

  private Counter c;

  @BeforeEach
  void setUp() {
    c = new Counter();
  }

  @Test
  void startsWithEmptyCount() {
    assertEquals(0, c.getCount());
  }

  @Test
  void countsIntegersAboveTen() {
    c.count(11);
    assertEquals(1, c.getCount());
  }

  @Test
  void doesNotCountIntegersBelowTen() {
    c.count(9);
    assertEquals(0, c.getCount());
  }
}

Die Testsuite ist nun auch ein guter Kandidat für Mutation Testing. Wenn wir PIT ausführen, stellen wir fest, dass (in der Defaultkonfiguration) vier Mutants erzeugt wurden und davon drei mit unserer Testsuite getötet werden konnten (Abb. 1).

Abb. 1: Counter.java

Abb. 1: Counter.java

Der überlebende Mutant ist changed conditional boundary, also die Mutation von <= zu <. Und tatsächlich fehlt uns ein Test für unsere Wertgrenze (Listing 6). Wenn wir ihn nachliefern, können alle Mutanten getötet werden.

@Test
void countsTen() {
  c.count(10);
  assertEquals(1, c.getCount());
}

Was wir in diesem Beispiel nicht gesehen haben, ist ein Equivalent Mutant. Bewusst Code zu schreiben, der anfällig für Equivalent Mutants ist, ist gar nicht so einfach, doch wir wollen uns einmal das Beispiel in Listing 7 anschauen.

public boolean isFoobar() {

  boolean foobar;
  if (1 > 0) {
    foobar = true;
  }

  return foobar;
}

@Test
void isFoobar() {
  assertTrue(c.isFoobar());
}

Hier erzeugt PIT den Mutant replaced boolean return with true, der return foobar durch return true ersetzt. Diesen können wir natürlich mit keinem vorstellbaren Test töten, da der aktuelle Code ja bereits in jedem Fall true als Wert zurückliefern wird. An dieser Stelle (und in vielen anderen Fällen) kann der Equivalent Mutant allerdings ein Hinweis auf potenziell zu vereinfachenden Code sein.

Fazit

Wir haben gesehen, dass Mutation Testing eine mächtige Technik ist, die uns verschiedene Vorteile bietet. Wir können fehlende Test-Cases oder fehlerhafte Tests identifizieren, und wir haben ein Sicherheitsnetz, um unsere Tests einem Refactoring zu unterziehen. Außerdem können durch Equivalent Mutants Hinweise auf Code Smells und redundanten Code gegeben werden. Zusammenfassend noch einmal der Hinweis, dass Mutation Testing in erster Linie ein Tool ist, das beim Entwickeln unterstützen soll und sich nur schlecht als Metrik für managementgetriebene Quality Gates eignet. Außerdem möchte ich hier die Empfehlung an jeden aussprechen, der sich nach diesem Artikel von Mutation Testing angesprochen fühlt: Probiert PIT am besten einfach einmal in einem bestehenden Projekt aus.

Verwandte Themen:

Geschrieben von
Kevin Wittek
Kevin Wittek
Kevin Wittek ist Java-Entwickler und JVM-Fan, seit er vor dreizehn Jahren der Sprache begegnete. Inzwischen fungiert Kevin als Software Engineer für die codecentric AG und beschäftigt sich dort mit den Themen Software Craftsmanship, Infrastructure as Code und Continous Integration Pipelines. In seiner Freizeit ist er Musiker, aktives Mitglied der Open-Source-Community und arbeitet als Co-Maintainer am Testcontainers-Projekt.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: