Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

Mutations-Testing mit PIT (PITest) ist eine fortgeschrittene Methode zur Bewertung der Qualität von Testsuiten. PIT modifiziert den Quellcode automatisch durch kleine Änderungen (Mutationen) und prüft, ob die bestehenden Tests diese Abweichungen zuverlässig erkennen. Nicht getötete Mutanten weisen auf Lücken in der Testsuite hin.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/testing-good-practices/-/tree/main/pit-example
Keywords
Mutation Testing, PIT, PITest, Test, Mutation

Mutationstesten mit PIT

PIT (PITest) beantwortet eine andere Frage als JaCoCo. JaCoCo misst, ob Ihr Code von den Tests durchlaufen wird. PIT misst, ob Ihre Tests einen Fehler überhaupt bemerken würden.

Dazu verändert PIT Ihren kompilierten Code automatisch an vielen Stellen, jeweils minimal, und erzeugt so Mutanten. Beispiele: aus >= wird >, aus + wird -, aus * 2 wird / 2, eine if-Bedingung wird negiert, ein Rückgabewert wird durch 0 ersetzt. Für jeden Mutanten lässt PIT Ihre Testsuite erneut laufen:

  • Schlägt mindestens ein Test fehl, ist der Mutant getötet (killed). Gut: Ihre Tests haben die Veränderung bemerkt.
  • Bleiben alle Tests grün, hat der Mutant überlebt (survived). Das ist eine Lücke: Sie haben die Stelle zwar ausgeführt, aber nicht wirklich geprüft.

Der Anteil getöteter Mutanten (Mutation Coverage) misst damit die Wirksamkeit Ihrer Tests, nicht nur deren Reichweite.

Wie Sie PIT lokal ausführen und den Report finden, beschreibt die Infopage JaCoCo und PIT lokal ausführen.

Warum nicht Zeilenabdeckung allein?

Hohe Coverage sagt wenig über die Qualität der Tests. In diesem Projekt zeigt das die bewusst schwache Testsuite für LoyaltyPoints (siehe unten): Sie erreicht rund 91 % Zeilenabdeckung, aber nur 38 % Mutation Coverage, von 13 Mutanten überleben 8. Coverage färbt fast alles grün, PIT deckt auf, dass die Tests kaum etwas wirklich prüfen.

Den Report lesen

Übersicht

Die Übersicht listet pro Package und Klasse drei Zahlen:

  • Line Coverage - wie bei JaCoCo, welcher Anteil der Zeilen lief.
  • Mutation Coverage - welcher Anteil der Mutanten getötet wurde.
  • Test Strength - getötete Mutanten bezogen nur auf die abgedeckten Mutanten. Klafft hier eine Lücke zur Line Coverage, prüfen Ihre Tests den ausgeführten Code nicht ernsthaft.

PIT Übersicht

Klassenansicht

Ein Klick auf eine Klasse zeigt den Quelltext. Jede Zeile, an der PIT mutiert hat, ist markiert und unter dem Code nummeriert aufgelistet, mit Status und angewandtem Mutator:

  • getötet (KILLED) - grün hinterlegt. Hier hat ein Test angeschlagen.
  • überlebt (SURVIVED) - rot hinterlegt. Hier hätte ein Test anschlagen müssen, tat es aber nicht. Das sind die Stellen, an denen Sie arbeiten.
  • nicht abgedeckt (NO_COVERAGE) - die Zeile wurde gar nicht ausgeführt; hier fehlt schon ein Test, der sie überhaupt erreicht.

PIT Klassensicht

Die wichtigsten Mutatoren

PIT verwendet hier die Standard-Mutatoren, darunter:

  • Conditionals Boundary - verschiebt Grenzen (< zu <=, > zu >=).
  • Negate Conditionals - dreht eine Bedingung um.
  • Math - tauscht arithmetische Operatoren (+ - * / %).
  • Increments sowie Return-Werte (ersetzt Rückgaben durch true/false/0/null).

Wichtig ist eine Eigenheit: PIT mutiert nur primitive Arithmetik. Rechnen Sie mit BigDecimal oder verstecken Sie die Logik hinter Methodenaufrufen, findet PIT kaum Angriffsfläche. Genau deshalb ist das Beispiel hier bewusst mit einfachen int-Operationen geschrieben.

Das Beispiel: LoyaltyPoints

LoyaltyPoints.forTicket(...) berechnet Bonuspunkte für ein Bahnticket mit einfachen int-Operationen: Punkte pro Euro, Bonus für lange Strecken, Verdopplung für Vielfahrer, eine Obergrenze.

    public int forTicket( int fareEuros, int distanceKm, boolean frequentTraveller ) {
        if ( fareEuros < 0 || distanceKm < 0 ) {
            throw new IllegalArgumentException( "fare and distance must not be negative" );
        }
        int points = fareEuros * POINTS_PER_EURO;
        if ( distanceKm >= LONG_DISTANCE_KM ) {
            points += LONG_DISTANCE_BONUS;
        }
        if ( frequentTraveller ) {
            points *= FREQUENT_TRAVELLER_FACTOR;
        }
        if ( points > MAX_POINTS_PER_TICKET ) {
            points = MAX_POINTS_PER_TICKET;
        }
        return points;
    }

Die mitgelieferte Testsuite ist absichtlich schwach: Sie ruft die Methode in jedem Zweig auf (daher die hohe Zeilenabdeckung), prüft aber nur grobe Eigenschaften, nie einen konkreten Wert:

  • dass überhaupt Punkte herauskommen (> 0),
  • dass eine lange Fahrt mindestens so viele Punkte bringt wie eine kurze (>=),
  • dass die Obergrenze nicht überschritten wird (<=).
class LoyaltyPointsTest {
    private final LoyaltyPoints loyaltyPoints = new LoyaltyPoints();

    @Test
    void aTicketEarnsPoints() {
        assertTrue( loyaltyPoints.forTicket( 30, 50, false ) > 0 );
    }

    @Test
    void aLongTripEarnsAtLeastAsMuchAsAShortTrip() {
        assertTrue( loyaltyPoints.forTicket( 30, 100, false ) >= loyaltyPoints.forTicket( 30, 50, false ) );
    }

    @Test
    void aFrequentTravellerEarnsAtLeastAsMuch() {
        assertTrue( loyaltyPoints.forTicket( 30, 50, true ) >= loyaltyPoints.forTicket( 30, 50, false ) );
    }

    @Test
    void pointsNeverExceedTheCap() {
        assertTrue( loyaltyPoints.forTicket( 100000, 100000, true ) <= 1000 );
    }
}

Im PIT-Report zu LoyaltyPoints.java (siehe oben) sehen Sie, was diese Lücken kosten. Einige der überlebenden Mutanten und warum sie durchrutschen:

  • fareEuros * POINTS_PER_EURO wird zu fareEuros / POINTS_PER_EURO. Der Test prüft nur > 0, auch eine Division ergibt etwas Positives, also bleibt er grün.
  • Die Grenze distanceKm >= 100 wird zu > 100. Bei genau 100 km entfällt dann der Bonus. Da der Test nur lang >= kurz vergleicht (und ohne Bonus eben 60 >= 60 gilt), merkt er nichts.
  • Die Bedingung if (frequentTraveller) wird negiert, die Verdopplung entfällt also. Der >=-Vergleich “Vielfahrer bekommt mindestens so viel” ist auch dann noch wahr.
  • Die Obergrenze points > MAX wird zu >=. Ohne einen Test, der genau an der Grenze prüft, fällt der Unterschied nicht auf.

Jeder überlebende Mutant zeigt also präzise auf eine fehlende Prüfung.

Die Lücken schließen

Die Behebung ist immer dieselbe Idee: Statt grober Ungleichungen konkrete Werte behaupten (z. B. genau 110 Punkte für eine bestimmte Fahrt) und gezielt die Grenzen abklopfen (genau 100 km, genau an der Obergrenze, und auch eine negative Eingabe). Schreiben Sie pro überlebendem Mutanten den Test, der ihn töten würde, und lassen Sie PIT erneut laufen: Die roten Stellen werden grün, und die Mutation Coverage steigt. Den konkreten Testcode dazu schreiben Sie selbst, das ist die eigentliche Übung.