Mutation Testing mit Infection in Symfony: Testqualität jenseits der Coverage messen
AI generated
SF
{ }
Symfony · Infection · Mutation Testing · PHP 8.4
Mutation Testing mit Infection in Symfony
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.

18 Min. Lesezeit Infection · Mutation Score Indicator · Baseline Symfony 7 · PHPUnit 11

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.

11. FAQ: Mutation Testing mit Infection in Symfony

1Was ist Mutation Testing?
Künstliche Fehler werden in den Code eingeschleust, um zu prüfen, ob die Testsuite sie tatsächlich erkennt.
2Was ist Infection PHP?
Die etablierte PHP-Implementierung von Mutation Testing als Composer-Dev-Abhängigkeit.
3Was ist der Mutation Score Indicator?
Prozentsatz aller Mutanten, die von der Testsuite tatsächlich getötet wurden.
4Warum reicht Coverage nicht?
Coverage misst nur Ausführung, nicht ob eine Assertion das Ergebnis tatsächlich prüft.
5Was ist ein Mutant?
Eine minimal veränderte Kopie des Codes, etwa mit geändertem Operator oder negierter Bedingung.
6Was bedeutet ein escaped Mutant?
Die Testsuite blieb trotz der Codeänderung grün, ein Signal für eine fehlende Assertion.
7Wie fuehre ich es ein?
Mit einer Baseline-Strategie: aktuellen MSI festhalten, CI-Pipeline gegen Absinken prüfen.
8Wie lange dauert ein voller Lauf?
Deutlich länger als normal, Diff-basierte Läufe in CI plus vollständiger nächtlicher Lauf empfohlen.
9Alle Mutatoren aktivieren?
Nicht zwingend, manche liefern wenig Mehrwert und erzeugen hauptsächlich Rauschen.
10Jeder escaped Mutant ein neuer Test?
Nein, nur fachlich relevante überlebte Mutanten rechtfertigen in der Regel einen neuen Test.