Testqualität jenseits der Coverage messen
Hundert Prozent Code Coverage sagt nichts darüber aus, ob Tests tatsächlich Bugs erkennen würden. Mutation Testing mit Infection schleust künstlich kleine Fehler in den Symfony Code ein und prüft, ob die Testsuite jeden einzelnen davon zuverlässig auffängt.
Inhaltsverzeichnis
- 1. Warum Code Coverage die falsche Kennzahl ist
- 2. Wie Mutation Testing funktioniert
- 3. Infection in einem Symfony Projekt einrichten
- 4. Den Mutation Score Indicator interpretieren
- 5. Mutatoren verstehen und gezielt auswählen
- 6. Escaped Mutants analysieren und Tests nachschärfen
- 7. Baseline-Strategie für gewachsene Codebasen
- 8. Typische Fehler beim Einsatz von Mutation Testing
- 9. Mutation Testing im Vergleich zu Coverage-Metriken
- 10. Zusammenfassung
- 11. FAQ
1. Warum Code Coverage die falsche Kennzahl ist
Code Coverage misst, welche Codezeilen während eines Testlaufs ausgeführt wurden, nicht ob die Tests tatsächlich prüfen, dass diese Zeilen das Richtige tun. Eine Zeile kann durchlaufen werden, ohne dass eine einzige Assertion ihr Ergebnis validiert, und trotzdem als "gedeckt" gelten. Genau diese Lücke schließt Mutation Testing: Statt zu fragen, ob Code ausgeführt wurde, fragt es, ob ein Fehler in diesem Code von der Testsuite bemerkt würde.
Das Werkzeug Infection ist die etablierte PHP-Implementierung von Mutation Testing und funktioniert für Symfony Projekte ohne größere Anpassungen an der bestehenden PHPUnit-Konfiguration. Infection verändert den produktiven Code systematisch in kleinen Schritten, etwa indem es > zu >= ändert oder eine Bedingung negiert, führt die Testsuite gegen jede dieser Veränderungen aus und protokolliert, welche Veränderungen unentdeckt bleiben.
Dieser Artikel zeigt, wie Mutation Testing im Detail funktioniert, wie Infection in ein Symfony Projekt eingebunden wird, wie der Mutation Score Indicator zu interpretieren ist und wie eine Baseline-Strategie hilft, das Werkzeug auch in gewachsenen Codebasen produktiv einzusetzen.
2. Wie Mutation Testing funktioniert
Der Kernmechanismus von Mutation Testing ist einfach beschrieben, aber weitreichend in der Wirkung: Infection erzeugt für jede infrage kommende Codezeile einen sogenannten Mutanten, eine minimal veränderte Kopie des Originalcodes. Ein typischer Mutant ändert einen Vergleichsoperator, entfernt eine Methode aus einer Kette, oder ersetzt einen Rückgabewert durch einen anderen plausiblen Wert. Für jeden Mutanten läuft anschließend die vollständige Testsuite, aber nur gegen diesen einen veränderten Code.
Fängt mindestens ein Test den Mutanten ab, indem er fehlschlägt, gilt der Mutant als "killed", getötet. Läuft die komplette Testsuite trotz der Codeänderung grün durch, gilt der Mutant als "escaped", entkommen. Ein hoher Anteil entkommener Mutanten in einer bestimmten Klasse zeigt präzise, wo die Testsuite Lücken hat, die reine Coverage-Zahlen niemals sichtbar machen würden, weil die Zeile ja technisch ausgeführt wurde, nur eben ohne echte Prüfung.
3. Infection in einem Symfony Projekt einrichten
Infection wird als Composer-Dev-Abhängigkeit installiert und benötigt eine Konfigurationsdatei infection.json5 im Projekt-Root, die unter anderem den Pfad zur PHPUnit-Konfiguration und das minimale Mutation Score Indicator-Ziel definiert. Für ein typisches Symfony Projekt reicht eine schlanke Konfiguration, die auf die vorhandene phpunit.xml.dist verweist und den Analyseumfang auf das src/-Verzeichnis beschränkt, ohne generierten Code oder Symfony-Kernklassen einzubeziehen.
// infection.json5
{
"$schema": "vendor/infection/infection/resources/schema.json",
"source": {
"directories": ["src"],
"excludes": ["src/Kernel.php", "src/DataFixtures"]
},
"logs": {
"text": "var/infection/infection.log",
"summary": "var/infection/summary.log",
"html": "var/infection/infection.html"
},
"mutators": {
"@default": true
},
"minMsi": 70,
"minCoveredMsi": 80
}
#!/usr/bin/env bash
# Run mutation testing with existing PHPUnit coverage cache for speed.
set -euo pipefail
vendor/bin/phpunit --coverage-xml=var/coverage/xml --log-junit=var/coverage/junit.xml
vendor/bin/infection --coverage=var/coverage --threads=4 --min-msi=70
4. Den Mutation Score Indicator interpretieren
Der Mutation Score Indicator, kurz MSI, ist der Prozentsatz der erzeugten Mutanten, die von der Testsuite tatsächlich getötet wurden. Ein MSI von 100 Prozent bedeutet, dass jede künstlich eingeschleuste Codeänderung mindestens einen Testfehlschlag ausgelöst hat, ein MSI von 60 Prozent bedeutet, dass beinahe die Hälfte aller getesteten Codeänderungen unbemerkt geblieben wäre. Infection unterscheidet zusätzlich zwischen dem globalen MSI und dem "Covered MSI", der nur Mutanten in bereits von Coverage erfassten Zeilen berücksichtigt.
Ein niedriger MSI in einer bestimmten Klasse ist kein Grund zur Panik, sondern ein konkreter Hinweis, wo genau die Testsuite blind ist. Anders als eine allgemeine Coverage-Prozentzahl zeigt der Mutation Score Indicator pro Klasse und sogar pro Methode, welche Tests fehlen, weil das Infection-HTML-Report exakt auflistet, welcher Mutant überlebt hat und an welcher Zeile.
5. Mutatoren verstehen und gezielt auswählen
Infection bringt dutzende Mutatoren mit, gruppiert nach Kategorien wie Arithmetic, Boolean, ConditionalBoundary und ReturnValue. Der ConditionalBoundary-Mutator ändert etwa $stock > 0 zu $stock >= 0, ein klassischer Off-by-one-Fehler, den viele Testsuiten in der Praxis übersehen, weil sie nur den offensichtlichen Fall testen, aber nicht die exakte Grenze.
Für Symfony Projekte mit vielen Doctrine-Entitäten und DTOs lohnt sich eine gezielte Auswahl der Mutatoren statt der vollständigen Default-Menge, weil manche Mutatoren, etwa solche, die reine Getter-Methoden betreffen, selten echten fachlichen Mehrwert liefern und hauptsächlich Rauschen erzeugen. Eine Konfiguration, die sich auf @default minus einiger als irrelevant identifizierter Mutatoren stützt, liefert oft ein aussagekräftigeres Signal als die volle Mutatorenliste.
// infection.json5 — targeting specific mutator categories for pricing logic
{
"mutators": {
"@arithmetic": true,
"@conditional_boundary": true,
"@boolean": true,
"PublicVisibility": false, // low signal for a DTO-heavy codebase
"MethodCallRemoval": true
}
}
6. Escaped Mutants analysieren und Tests nachschärfen
Der wertvollste Schritt nach einem Infection-Lauf ist, den HTML-Report für entkommene Mutanten durchzugehen und für jeden einzeln zu entscheiden, ob ein fehlender Test nachgeholt werden muss. Nicht jeder überlebte Mutant rechtfertigt automatisch einen neuen Test: Manche Mutanten betreffen tatsächlich irrelevanten Code, etwa Logging-Aufrufe ohne Rückwirkung auf das Ergebnis, bei denen ein zusätzlicher Test wenig Mehrwert brächte.
Bei fachlich relevanten Mutanten, etwa einem entkommenen Mutanten in einer Preisberechnung oder einer Rabattlogik, ist der überlebte Mutant fast immer ein direkter Hinweis auf eine fehlende Assertion. Häufig reicht eine einzige zusätzliche Zeile in einem bestehenden Data Provider, etwa ein Grenzwertfall mit exakt null oder exakt dem Maximalwert, um den entsprechenden Mutanten beim nächsten Lauf zu töten.
7. Baseline-Strategie für gewachsene Codebasen
Ein bestehendes Symfony Projekt mit mehreren Jahren Historie hat selten einen MSI von 90 Prozent, wenn Mutation Testing zum ersten Mal eingeführt wird. Ein hartes --min-msi=90 in der CI-Pipeline würde jeden Build sofort rot färben und das Werkzeug schnell unpopulär machen. Der pragmatische Weg ist eine Baseline: Der aktuelle MSI wird als Ausgangswert festgehalten, die CI-Pipeline verlangt zunächst nur, dass der MSI nicht unter diesen Wert fällt, statt einen utopischen Zielwert sofort zu erzwingen.
Mit dieser Baseline-Strategie kann der Schwellenwert schrittweise angehoben werden, sobald neue oder verbesserte Tests den MSI in relevanten Modulen anheben. Infection unterstützt außerdem einen Modus, der nur Mutanten in geänderten Dateien eines Pull Requests analysiert, was gerade bei großen Legacy-Codebasen den Fokus auf neuen oder geänderten Code lenkt, statt die komplette historische Codebasis in jedem CI-Lauf neu zu bewerten.
#!/usr/bin/env bash
# Mutation testing scoped to files changed in the current merge request.
set -euo pipefail
git diff origin/main --name-only --diff-filter=ACMR -- 'src/*.php' > var/infection/changed-files.txt
vendor/bin/infection \
--filter=$(paste -sd, var/infection/changed-files.txt) \
--min-msi=70 \
--threads=4
8. Typische Fehler beim Einsatz von Mutation Testing
Der häufigste Fehler ist, Mutation Testing gleich zu Beginn mit einem zu hohen Schwellenwert in die CI-Pipeline zu zwingen, ohne eine Baseline-Phase einzuplanen. Das führt entweder zu dauerhaft roten Builds, die ignoriert werden, oder zu übereilten, oberflächlichen Tests, die nur den Mutanten töten, ohne echten fachlichen Wert zu haben, etwa ein Test, der lediglich prüft, dass eine Methode aufgerufen wurde, ohne das Ergebnis zu validieren.
<?php
// WRONG: test written only to "kill the mutant", checks nothing meaningful
public function testCalculateDiscount(): void
{
$result = $this->calculator->applyDiscount(100.0, 10);
self::assertNotNull($result); // kills some mutants, proves nothing
}
// RIGHT: test asserts the actual expected business value
public function testCalculateDiscount(): void
{
$result = $this->calculator->applyDiscount(100.0, 10);
self::assertEqualsWithDelta(90.0, $result, 0.001);
}
Ein zweiter Fehler ist, Infection auf der vollständigen Codebasis bei jedem Commit laufen zu lassen. Bei größeren Projekten kann ein vollständiger Mutation-Testing-Lauf mehrere Stunden dauern, weil die Testsuite für jeden einzelnen Mutanten erneut ausgeführt wird. Die im vorherigen Abschnitt gezeigte Diff-basierte Strategie hält die Laufzeit in der CI-Pipeline praktikabel, während ein vollständiger, nächtlicher Lauf das Gesamtbild im Blick behält.
9. Mutation Testing im Vergleich zu Coverage-Metriken
Code Coverage und Mutation Testing beantworten unterschiedliche Fragen und ergänzen sich, statt sich zu ersetzen. Die folgende Tabelle stellt beide Metriken gegenüber.
| Frage | Code Coverage | Mutation Score Indicator | Konsequenz |
|---|---|---|---|
| Wurde die Zeile ausgeführt? | Ja, direkt gemessen | Indirekt über Mutanten in dieser Zeile | Coverage reicht für diese Frage aus |
| Wird ein Fehler dort erkannt? | Nein, keine Aussage möglich | Ja, direktes Ziel der Metrik | Nur Mutation Testing beantwortet das |
| Laufzeit | Ein Testlauf | Ein Testlauf pro Mutant | Mutation Testing ist deutlich teurer |
| Aussagekraft bei 100% Coverage | Kann trotzdem lückenhaft sein | Deckt fehlende Assertions auf | Beide Metriken zusammen nutzen |
Die pragmatische Kombination: Code Coverage als schnelle, günstige Basiskennzahl in jedem CI-Lauf, Mutation Testing als tiefere, aber teurere Prüfung auf Pull-Request-Diff-Ebene und in regelmäßigen vollständigen Läufen außerhalb des kritischen Pfads.
Mironsoft
Symfony Testqualität, Infection und CI-Pipelines
Wissen, ob eure Tests echte Bugs erkennen würden?
Wir führen Infection in bestehende Symfony Testsuiten ein, definieren eine realistische Baseline-Strategie und schärfen gezielt die Tests nach, die entkommene Mutanten aufdecken.
Infection-Setup
Konfiguration, Mutatoren-Auswahl und CI-Integration
Baseline-Analyse
Aktuellen Mutation Score Indicator ermitteln und Zielwerte festlegen
Test-Nachschärfung
Escaped Mutants priorisieren und fehlende Assertions ergänzen
10. Zusammenfassung
Mutation Testing mit Infection beantwortet eine Frage, die Code Coverage strukturell nicht beantworten kann: Würde die Testsuite einen echten Bug tatsächlich bemerken? Indem Infection künstliche Fehler in den Symfony Code einschleust und prüft, ob mindestens ein Test fehlschlägt, deckt der Mutation Score Indicator präzise auf, wo Assertions fehlen, selbst in Zeilen, die nach klassischer Coverage-Messung längst als "getestet" gelten.
Für gewachsene Symfony Projekte ist eine Baseline-Strategie der praktikable Einstieg: den aktuellen MSI festhalten, schrittweise anheben, und Diff-basierte Läufe auf Pull-Request-Ebene mit vollständigen, nächtlichen Läufen kombinieren. Wer Mutation Testing so einführt, bekommt ein präzises Werkzeug, um Testlücken zu finden, ohne die CI-Pipeline unpraktikabel zu verlangsamen.
Mutation Testing mit Infection in Symfony: Das Wichtigste auf einen Blick
Andere Frage als Coverage
Coverage misst Ausführung, Mutation Testing misst, ob ein Fehler erkannt würde.
Mutation Score Indicator
Prozentsatz getöteter Mutanten, pro Klasse und Methode im HTML-Report einsehbar.
Baseline statt hartem Ziel
Aktuellen MSI als Startwert festhalten, schrittweise anheben statt sofort 90% zu erzwingen.
Diff-basierte CI-Läufe
Nur geänderte Dateien pro Pull Request analysieren, vollständige Läufe nächtlich.