Blog: Java, Software-Entwicklung & mehr

Bessere Testergebnisse mit PI-Test

19.01.2022 Michael Mosmann

Warum Code Coverage nicht reicht


Code Coverage kann trügerisch sein, wenn es um die Qualität von Unit-Tests geht. Mutationstests wie PI-Test bieten eine zusätzliche Sicherheit.

In den meisten Projekten wird der Projektcode nicht nur durch Unit- und Integrationstests getestet, sondern zusätzlich die Codecoverage gemessen, also geprüft, welcher Code durch den Testlauf tatsächlich zur Ausführung kam. Bei der Codecoverage strebt man einen hohen Wert an (mehr als 70% des Codes müssen durch Tests ausgeführt worden sein). In wenigen Projekten erwartet man, dass fast der ganze oder sogar der ganze Code abgedeckt wird. Dabei sind 100% eigentlich unerreichbar.

Ein einfaches Beispiel

Angenommen wir haben eine wichtige Funktion, die zwei Zahlen addiert. Da sie in unserem Projekt überall zum Einsatz kommt, müssen wir sicher stellen, dass die Implementierung korrekt ist und sich keine neuen Fehler einschleichen.


public abstract class Numbers {
  static int add(int a, int b) {
    return a + b;
  }
}

Mit folgendem Test prüfen wir die Implementierung anhand eines bekannten Zahlenpaares:


public class NumbersTest {
  @Test
  void resultIsSumOfBoth() {
    assertThat(Numbers.add(2,2)).isEqualTo(4);
  }
}

Der Test ist grün, in der Implementierung wurde der ganze Code der Methode ausgeführt und die Codecoverage ist hoch (in Intellij liegt sie bei 100%, jacoco vermeldet 57%). Das Beispiel ist stark vereinfacht, spiegelt aber in etwa die Situation in den meisten Projekten wieder. Es gibt einen Test der grün ist, d.h. es gibt keine offensichtlichen Fehler in der Implementierung.

Angenommen, jemand ändert die Implementierung der Funktion und verlässt sich darauf, dass der Test (für den Fall, dass er einen Fehler gemacht hat) entsprechend fehl schlägt. Wenn in diesem Beispiel die Berechnung von a + b auf a * b geändert wird, bleibt der Test grün. Das liegt zum einen daran, dass die Werte im Test nicht geschickt gewählt wurden und zum anderen, dass es nur einen Test gibt.

In diesem Beispiel ist das alles offensichtlich, in einem Projekt mit mehr Code und komplexeren Strukturen fällt ein offensichtlich schwacher Test nicht auf. Die Menge an Tests und dass Tests oft viel mehr als nur eine Klasse oder eine Methode testen, führt dazu, dass die Codecoverage einen hohen Wert erreichen kann und sich trotzdem neue Fehler einschleichen können. Auch eine Quote von 100% ändert daran nichts, wenn z.B. die Rückgabewerte nicht geprüft werden.

Alternative Teststrategien

Eine Möglichkeit ungeschickt gewählten Werten in Tests entgegen zu treten besteht darin, keine konstanten Werte, sondern immer nur generierte Werte zu benutzen. Auf die Weise erschließt man sich auch auf einfacher Weise Grenzfälle, die in Tests meistens zu kurz kommen. Wer sich eingehender damit beschäftigen möchte, kann sich zum Beispiel https://jqwik.net/ ansehen.

Ein anderer Ansatz beruht darauf, dass geprüft wird, ob Fehler im Code durch die eigenen Tests bemerkt werden würde. Der Bytecode wird dahingehend modifiziert, das verschiedene Fehlerklassen eingebaut werden und dann geprüft wird, ob die Tests weiterhin der Meinung sind, dass alles fehlerfrei ist. Es werden also absichtlich Fehler eingebaut. PI Test ist ein Plugin, dass diese Art von Analyse ermöglicht.

Mutationstests mit PI Test

PI Test ist kein neues Projekt. Das es noch nicht in jedem Projekt anzutreffen ist, liegt an verschiedenen Gründen. Einer davon ist die Dauer die PI Test für einen Durchlauf benötigt, da mit jedem neuen Fehler ein neuer Testlauf nötig ist. Es werden also alle Tests mehrfach ausgeführt. In vielen Projekten benötigen die Tests ohnehin schon zu viel Zeit, sodass PI Test diese Situation nicht verbessern würde.

Ein zweiter Grund: PI Test zeigt sehr zuverlässig, welche Fehler man mit den bisherigen Tests nicht gefunden hätte. Das kann bei einem bestehenden Projekt so umfangreich sein, dass man das Gefühl bekommt, gegen Windmühlen zu kämpfen. Wenn man mit den Erkenntnissen, die man durch PI Test gewinnen kann nichts anfängt, ist die Zeit zu Schade, die man dafür investiert hat.

Aber wenn man Code hat, bei dem man sich wirklich darauf verlassen muss, das er korrekt ist und korrekt bleibt, kann PI Test das unbemerkte Einführen neuer Fehler verhindern.

PI Test in der Anwendung

Um PI Test in einem Projekt zu aktivieren, bindet man es z.B. als Maven-Plugin ein. Diese Konfiguration funktioniert mit JUnit5 (es gibt weitreichende Konfigurationsmöglichkeiten, auf die in diesem Artikel nicht näher eingegangen wird).


<plugin>
    <groupId>org.pitest</groupId>
    <artifactId>pitest-maven</artifactId>
    <version>1.7.2</version>
    <executions>
        <execution>
            <goals>
                <goal>mutationCoverage</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <mutators>
            STRONGER,AOR,AOD,OBBN,ROR,REMOVE_INCREMENTS,NON_VOID_METHOD_CALLS,INLINE_CONSTS,CONSTRUCTOR_CALLS
        </mutators>
        <timestampedReports>false</timestampedReports>
        <verbose>false</verbose>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>org.pitest</groupId>
            <artifactId>pitest-junit5-plugin</artifactId>
            <version>0.15</version>
        </dependency>
    </dependencies>
</plugin>

Wenn man dann den Test für Numbers laufen lässt, erhält man folgende Auswertung:

PI-Test Step 01

Wie man sieht, konnten ein paar Fehler eingebaut werden, die durch die bestehenden Tests nicht bemerkt worden wären.

Bessere Tests

Schauen wir uns nun ein etwas komplexeres Beispiel an. Wir wollen wissen, ob eine Zahl in einem Wertebereich liegt. Eine erste Implementierung ist offensichtlich:


public abstract class Numbers {
  static boolean isInRange(int value, int min, int max) {
    if (value<min) return false;
    if (value>max) return false;
    return true;
  }
}

Ob die Implementierung korrekt ist, prüfen wir mit drei Tests:


public class NumbersTest {
  @Test
  void valueIsBetween() {
    assertThat(Numbers.isInRange(2,1,4)).isTrue();
    assertThat(Numbers.isInRange(3,1,4)).isTrue();
  }

  @Test
  void valueIsSmaller() {
    assertThat(Numbers.isInRange(0,1,4)).isFalse();
  }

  @Test
  void valueIsBigger() {
    assertThat(Numbers.isInRange(5,1,4)).isFalse();
  }
}

PI Test findet in diesem Beispiel ebenfalls Möglichkeiten, wie sich Fehler einschleichen können:

PI-Test Step 02

Dabei müssen wir eigentlich nicht viel anpassen, damit auch dieser Fehler durch einen passenden Test entdeckt werden kann:

Ob die Implementierung korrekt ist, prüfen wir mit drei Tests:


public class NumbersTest {

  @Test
  void valueIsBetween() {
    assertThat(Numbers.isInRange(3, 3, 4)).isTrue();
    assertThat(Numbers.isInRange(4, 3, 4)).isTrue();
  }

  @Test
  void valueIsSmaller() {
    assertThat(Numbers.isInRange(2, 3, 4)).isFalse();
  }

  @Test
  void valueIsBigger() {
    assertThat(Numbers.isInRange(4, 3, 3)).isFalse();
  }
}

Nun findet PI Test keine Möglichkeit mehr, unbemerkt einen Fehler einzuschleusen:

PI-Test Step 03

Der entscheidende Unterschied zwischen den Tests in diesem Beispiel ist einfach: Wir haben die Grenzfälle getestet. Davor haben wir zwar Testwerte genommen, für die die Implementierung eine korrekte Antwort geliefert hat, aber Fehler schleichen sich typischerweise an Grenzen ein.

Zusammenfassung

Mit Unit-Tests kann man sicherstellen, dass sich Funktionen und Klassen unter definierten Rahmenbedingungen erwartbar verhalten. Codecoverage hilft dabei, herauszufinden, welcher Code gar nicht oder zu wenig getestet wird. Mutationstests können dabei helfen, bessere Tests zu schreiben und so verhindern, dass Fehler unentdeckt bleiben oder unbemerkt hinzukommen.