Mutanten aufspüren, die eure PHPUnit-Suite einfach durchwinkt
Eine Test-Suite mit 100 Prozent Line Coverage kann trotzdem wertlos sein, wenn Tests Code lediglich ausführen statt sein Verhalten zu prüfen. Mutation Testing führt gezielt kleine Fehler in euren Code ein und prüft, ob eure Tests sie tatsächlich bemerken. Wer diese Technik für PHP und PHPUnit richtig einsetzt, deckt blinde Flecken auf, die klassische Coverage-Metriken systematisch verschleiern.
Inhaltsverzeichnis
- 1. Was Mutation Testing ist und wie es funktioniert
- 2. Der Mutation Score als stärkeres Qualitätssignal
- 3. Warum 100 Prozent Line Coverage trotzdem nichts garantiert
- 4. Infection für PHP und PHPUnit einrichten
- 5. Mutation Operators im Detail: Was Infection tatsächlich verändert
- 6. Einen schwachen Test in der Praxis aufdecken
- 7. Infection-Ergebnisse richtig interpretieren
- 8. Performance-Kosten und CI-Strategien für regelmäßige Läufe
- 9. Line Coverage gegen Mutation Score im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was Mutation Testing ist und wie es funktioniert
Mutation Testing dreht die gewohnte Testlogik um: Statt zu prüfen, ob euer Code für gegebene Eingaben die richtigen Ausgaben liefert, prüft es, ob eure Tests überhaupt bemerken, wenn der Code kaputtgeht. Ein Mutation-Testing-Tool wie Infection nimmt euren PHP-Quellcode, erzeugt systematisch kleine, syntaktisch gültige Varianten davon, sogenannte Mutanten, und führt für jeden einzelnen Mutanten eure komplette PHPUnit-Suite erneut aus. Ein Mutant entsteht durch einen einzigen gezielten Eingriff, etwa das Vertauschen von >= zu >, das Negieren eines Rückgabewerts oder das Entfernen eines gesamten Methodenaufrufs.
Schlägt mindestens ein Test fehl, gilt der Mutant als getötet, killed, eure Suite hat die Verhaltensänderung also tatsächlich bemerkt. Läuft die Suite dagegen unverändert grün durch, obwohl der Code jetzt nachweislich anders funktioniert, ist der Mutant entkommen, escaped. Genau diese entkommenen Mutanten sind der eigentliche Wert von Mutation Testing: Sie zeigen konkret, an welcher Stelle im Code eure Tests zwar ausgeführt werden, aber keine echte Aussage über das Verhalten treffen.
2. Der Mutation Score als stärkeres Qualitätssignal
Line Coverage beantwortet nur die Frage, ob eine Codezeile während des Testlaufs überhaupt einmal ausgeführt wurde. Der Mutation Score, von Infection als Mutation Score Indicator, MSI, ausgegeben, beantwortet die viel relevantere Frage: Wenn diese Zeile ein anderes Verhalten hätte, würde irgendein Test das bemerken? Der MSI berechnet sich näherungsweise aus der Anzahl getöteter Mutanten geteilt durch die Gesamtzahl der erzeugten Mutanten, abzüglich technisch nicht bewertbarer Fälle, und wird als Prozentwert ausgegeben.
In der Praxis klafft zwischen beiden Zahlen oft eine erhebliche Lücke. Ein Projekt mit 92 Prozent Line Coverage kann durchaus einen Mutation Score von nur 55 Prozent haben, weil viele Tests zwar durch den Code laufen, aber keine oder nur triviale Assertions enthalten. Dieser Abstand ist selbst eine Kennzahl: Je größer die Lücke zwischen Coverage und MSI, desto mehr Tests existieren nur auf dem Papier und verifizieren tatsächlich nichts von dem, was sie vorgeben zu prüfen.
3. Warum 100 Prozent Line Coverage trotzdem nichts garantiert
Ein Test, der eine Methode aufruft, ohne das Ergebnis mit einer Assertion zu prüfen, zählt für die Line Coverage genauso wie ein Test, der jedes Detail des Rückgabewerts akribisch verifiziert. Coverage-Werkzeuge messen ausschließlich, welche Zeilen der Interpreter durchlaufen hat, nicht, ob der Test danach irgendetwas Sinnvolles über das Verhalten dieser Zeilen aussagt. Dieses Phänomen heißt treffend Coverage without verification, Abdeckung ohne Verifikation, und ist in gewachsenen PHP-Codebasen erstaunlich häufig, insbesondere in Tests, die primär geschrieben wurden, um eine CI-Coverage-Schwelle zu erfüllen.
Typische Symptome sind Tests, die nur assertNotNull() auf ein Objekt aufrufen, obwohl das Objekt in praktisch jedem Fall ein Objekt ist, oder die eine Exception mit einem leeren catch-Block abfangen, sodass ein tatsächlicher Fehler den Test niemals rot färbt. Mutation Testing deckt genau solche Tests zuverlässig auf, weil ein Mutant, der die eigentliche Fachlogik verändert, trotzdem grün durchläuft, sobald die Assertion die Änderung schlicht nicht erfassen kann.
4. Infection für PHP und PHPUnit einrichten
Infection installiert ihr per composer require --dev infection/infection und initialisiert die Konfiguration interaktiv mit vendor/bin/infection --init, was eine infection.json5 im Projektroot anlegt. Die Konfiguration definiert, welche Verzeichnisse unter source.directories als Mutationsziel gelten, üblicherweise app/code bei einer Magento-2-Installation, und welcher Testrunner, meist phpunit, für die Verifikationsläufe verwendet wird. Wichtige Schwellenwerte sind minMsi für den globalen Mutation Score und minCoveredMsi, der sich nur auf tatsächlich von Tests erreichten Code bezieht und dadurch fair bleibt, wenn Teile der Codebasis bewusst noch keine Tests haben.
Der erste Lauf mit vendor/bin/infection erzeugt zunächst einen Coverage-Report, um zu wissen, welche Mutanten überhaupt von einem Test erreicht werden, bevor die eigentlichen Mutationsläufe starten. Mit --threads=4 lässt sich die Mutationsausführung parallelisieren, was auf einer Multi-Core-CI-Maschine die Laufzeit erheblich senkt. Für Magento-Projekte empfiehlt es sich, source.directories zunächst auf einzelne, gut getestete Module wie ein eigenes Mironsoft_SeoSuite-Modul zu beschränken, statt sofort die komplette app/code-Struktur zu mutieren.
// infection.json5
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": [
"app/code/Mironsoft/SeoSuite/Model",
"app/code/Mironsoft/SeoSuite/Service"
]
},
"logs": {
"text": "var/infection/infection.log",
"html": "var/infection/infection.html",
"summary": "var/infection/summary.log"
},
"mutators": {
"@default": true
},
"minMsi": 70,
"minCoveredMsi": 80,
"testFramework": "phpunit",
"timeout": 10
}
5. Mutation Operators im Detail: Was Infection tatsächlich verändert
Infection wendet dutzende vordefinierte Mutation Operators an, von denen jeder eine spezifische, minimale Codeänderung erzeugt. Der Conditional Boundary Operator verschiebt Vergleichsgrenzen, aus >= wird > und aus < wird <=, und deckt damit Off-by-One-Fehler auf, die in Preis- oder Mengenlogik besonders kritisch sind. Der Arithmetic Operator Replacement tauscht + gegen -, * gegen / und umgekehrt, was in Rabatt- oder Steuerberechnungen sofort sichtbar wird, sobald ein Test tatsächlich konkrete Zahlenwerte prüft.
Der Return Value Negation Operator kehrt Boolean-Rückgabewerte um, aus return true; wird return false;, und ist besonders aufschlussreich bei Methoden wie isValid() oder canApplyDiscount(), deren gesamter Zweck in einem einzigen Wahrheitswert liegt. Jeder Operator wird an jeder passenden Stelle im Quellcode einzeln angewendet, ein 200-Zeilen-Service kann dadurch problemlos vierzig oder fünfzig Mutanten erzeugen. Infection generiert für jeden Mutanten ein isoliertes Diff, das im HTML-Report direkt neben dem betroffenen Test einsehbar ist.
// Original code (app/code/Mironsoft/SeoSuite/Model/DiscountCalculator.php)
public function isEligibleForDiscount(float $orderTotal): bool
{
return $orderTotal >= 100.0;
}
// Mutant generated by Infection's ConditionalBoundary operator:
//
// public function isEligibleForDiscount(float $orderTotal): bool
// {
// return $orderTotal > 100.0;
// }
//
// A test that only asserts with $orderTotal = 150.0 cannot tell these
// two implementations apart, the mutant escapes.
6. Einen schwachen Test in der Praxis aufdecken
Nehmt eine einfache Rabattklasse, die ab einem Bestellwert von 100 Euro fünf Prozent Rabatt gewährt. Ein naiver Test ruft calculate() mit einem Bestellwert von 150 Euro auf und prüft lediglich, dass das Ergebnis eine Zahl größer null ist. Dieser Test erreicht 100 Prozent Line Coverage der Methode, weil jede Zeile durchlaufen wird, sagt aber nichts über die eigentliche Rabattlogik aus. Mutiert Infection nun die Bedingung von >= 100 zu > 100, bleibt der Test weiterhin grün, denn 150 Euro erfüllt beide Varianten der Bedingung gleichermaßen, der Mutant entkommt.
Ein gestärkter Test prüft stattdessen konkrete Grenzwerte: einen Bestellwert von genau 100 Euro, der den Rabatt gerade noch auslösen muss, und 99,99 Euro, der ihn gerade noch nicht auslösen darf, jeweils mit einer exakten Assertion auf den erwarteten Rabattbetrag. Genau dieser Grenzfalltest tötet den Conditional-Boundary-Mutanten zuverlässig, weil sich das Ergebnis bei 100 Euro zwischen >= und > tatsächlich unterscheidet. Diese Art von Boundary-Test entsteht in der Praxis fast nie ohne den konkreten Anstoß eines entkommenen Mutanten.
// app/code/Mironsoft/SeoSuite/Model/DiscountCalculator.php
final class DiscountCalculator
{
public function calculate(float $orderTotal): float
{
if ($orderTotal >= 100.0) {
return $orderTotal * 0.05;
}
return 0.0;
}
}
// Test/Unit/DiscountCalculatorTest.php (weak, coverage-only test)
final class DiscountCalculatorTest extends TestCase
{
public function testCalculateReturnsANumber(): void
{
$calculator = new DiscountCalculator();
$result = $calculator->calculate(150.0);
// Executes every line, but proves almost nothing about behavior
$this->assertGreaterThan(0, $result);
}
}
// Test/Unit/DiscountCalculatorTest.php (strengthened, boundary-aware test)
final class DiscountCalculatorTest extends TestCase
{
public function testDiscountAppliesExactlyAtThreshold(): void
{
$calculator = new DiscountCalculator();
// Boundary value: 100.0 must trigger the discount
$this->assertEqualsWithDelta(5.0, $calculator->calculate(100.0), 0.001);
}
public function testDiscountDoesNotApplyBelowThreshold(): void
{
$calculator = new DiscountCalculator();
// Boundary value: 99.99 must not trigger the discount
$this->assertSame(0.0, $calculator->calculate(99.99));
}
}
7. Infection-Ergebnisse richtig interpretieren
Infection klassifiziert jeden Mutanten nach dem Testlauf in einen von mehreren Zuständen. Killed bedeutet, mindestens ein Test ist fehlgeschlagen, der Mutant wurde erfolgreich erkannt. Escaped bedeutet, die Suite lief unverändert grün durch, obwohl sich das Verhalten geändert hat, das ist der kritische Fall. Timeout tritt auf, wenn ein Mutant eine Endlosschleife erzeugt, etwa durch eine invertierte Schleifenbedingung, und wird konservativ wie ein getöteter Mutant behandelt. Uncovered markiert Mutanten in Code, den kein einziger Test überhaupt erreicht, hier fehlt nicht die Assertion, sondern der Test selbst.
Der MSI im HTML- oder Text-Report fasst all das in einer einzigen Prozentzahl zusammen, ist aber ohne den Blick auf die einzelnen Escaped-Mutanten wenig handlungsleitend. In der Praxis lohnt es sich, Escaped-Mutanten nach betroffener Klasse zu sortieren und zuerst die Business-kritischen Bereiche wie Preisberechnung, Rabattlogik oder Bestandsprüfung durchzugehen. Manche Mutanten sind semantisch äquivalent zum Original, etwa eine Änderung in totem Code, und lassen sich über ignoreSourceCodeByRegex gezielt aus der Bewertung ausschließen, statt die MSI-Zielmarke künstlich zu senken.
8. Performance-Kosten und CI-Strategien für regelmäßige Läufe
Der größte praktische Nachteil von Mutation Testing ist die Laufzeit: Ein Projekt mit 2000 Tests und 800 Mutanten kann, selbst mit Test-Isolation durch Infections eingebauten Coverage-Filter, der pro Mutant nur die tatsächlich betroffenen Tests erneut ausführt, mehrere Minuten bis Stunden dauern. Bei jedem Commit die gesamte Codebasis zu mutieren, ist für die meisten Teams schlicht nicht praktikabel und würde jede Pipeline lahmlegen. Der pragmatische Kompromiss ist, in der regulären CI nur die durch den aktuellen Merge Request geänderten Dateien zu mutieren, über --git-diff-filter=AM in Kombination mit --git-diff-base.
Ein vollständiger Mutationslauf über die gesamte Codebasis gehört stattdessen in einen nächtlichen Scheduled-Job, der nicht die Pull-Request-Pipeline blockiert, sondern Ergebnisse als Trend-Report im Team-Chat postet. Der Parameter --min-msi=70 lässt die Pipeline mit einem Fehlercode abbrechen, sobald der Mutation Score unter die definierte Schwelle fällt, wodurch verifizierte Testqualität zur harten CI-Gate-Bedingung wird, ähnlich wie es minCoveredMsi bereits für abgedeckten Code erzwingt. Wichtig ist, die Schwelle schrittweise anzuheben, ein hartes 90-Prozent-Ziel am ersten Tag frustriert nur und führt dazu, dass Pipelines ignoriert werden.
# .gitlab-ci.yml
mutation-testing:
stage: test
image: php:8.4-cli
script:
- composer install --no-progress
- vendor/bin/phpunit --coverage-xml=var/coverage-xml --log-junit=var/junit.xml
- vendor/bin/infection
--coverage=var
--git-diff-filter=AM
--git-diff-base=origin/main
--min-msi=70
--min-covered-msi=80
--threads=4
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
mutation-testing-nightly:
stage: test
image: php:8.4-cli
script:
- composer install --no-progress
- vendor/bin/phpunit --coverage-xml=var/coverage-xml
- vendor/bin/infection --coverage=var --threads=8
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
9. Line Coverage gegen Mutation Score im direkten Vergleich
Die folgende Übersicht stellt Line Coverage und Mutation Score entlang der Dimensionen gegenüber, die für die tägliche Testarbeit tatsächlich relevant sind.
| Dimension | Line Coverage | Mutation Score | Praxisrelevanz |
|---|---|---|---|
| Grundaussage | Zeile wurde ausgeführt | Verhaltensänderung wurde bemerkt | MSI misst tatsächliche Testwirkung, nicht nur Durchlauf |
| Erkennt assertion-freie Tests | Nein | Ja | Escaped Mutants entlarven Coverage without verification |
| Erkennt Off-by-One-Fehler | Nein, solange die Zeile läuft | Ja, via Conditional Boundary | Kritisch bei Preis-, Rabatt- und Mengenlogik |
| Laufzeit | Sekunden | Minuten bis Stunden | Nur geänderte Dateien pro Commit mutieren, Vollscan nachts |
| Geeignet als CI-Gate bei jedem Commit | Ja, aber wenig aussagekräftig | Bedingt, meist auf geänderte Dateien beschränkt | min-msi als hartes Gate, schrittweise angehoben |
Mironsoft
Testautomatisierung und PHPUnit-Qualitätssicherung für Magento- und Hyvä-Shops
Wisst ihr wirklich, ob eure Tests etwas taugen?
Wir führen Mutation Testing mit Infection in eurer PHPUnit-Suite ein, decken schwache, assertion-freie Tests auf und bauen CI-Pipelines mit einem realistischen min-msi-Gate, statt euch mit einer trügerischen Coverage-Zahl allein zu lassen.
Test-Suite-Audit
Bestehende PHPUnit-Tests auf Mutation Score und schwache Assertions prüfen
Infection-Setup
infection.json5, Mutation Operators und sinnvolle MSI-Schwellenwerte einrichten
CI-Integration
Changed-Files-Läufe, nächtliche Vollscans und min-msi-Gating aufbauen
10. Zusammenfassung
Mutation Testing beantwortet die Frage, die Line Coverage niemals stellen kann: Bemerken eure Tests überhaupt, wenn sich das Verhalten des Codes ändert? Infection erzeugt dafür systematisch Mutanten über Operatoren wie Conditional Boundary, Arithmetic Operator Replacement und Return Value Negation, führt eure PHPUnit-Suite gegen jeden einzelnen Mutanten aus und klassifiziert das Ergebnis als killed, escaped, timeout oder uncovered. Der daraus berechnete Mutation Score Indicator ist ein deutlich härteres Qualitätssignal als reine Zeilenabdeckung, weil er nicht Ausführung, sondern tatsächliche Verifikation misst.
Der größte Stolperstein ist nicht die Technik, sondern die Laufzeit: Ein voller Mutationslauf über eine ganze Codebasis dauert zu lange für jeden einzelnen Commit. Die praxistaugliche Lösung kombiniert schnelle, auf geänderte Dateien beschränkte Läufe in der Merge-Request-Pipeline mit vollständigen nächtlichen Scans und einem schrittweise angehobenen min-msi-Schwellenwert, der die Pipeline nur dann blockiert, wenn die Testqualität nachweislich unter das definierte Niveau fällt. So wird Mutation Testing zu einem festen, aber tragbaren Bestandteil eurer Qualitätssicherung, statt zu einer einmalig laufengelassenen Kennzahl ohne Konsequenz.
Mutation Testing, Das Wichtigste auf einen Blick
Mutation Score
MSI misst getötete Mutanten im Verhältnis zu allen bewertbaren Mutanten, aussagekräftiger als reine Line Coverage.
Mutation Operators
Conditional Boundary, Arithmetic Operator Replacement und Return Value Negation erzeugen realistische, kleine Codefehler.
Ergebnis-Zustände
Killed, escaped, timeout und uncovered klassifizieren jeden Mutanten nach dem Testlauf.
CI-Strategie
Geänderte Dateien pro Commit mutieren, Vollscan nachts, min-msi schrittweise als Gate anheben.