Mutation Testing mit Infection als Qualitätshebel für PHPUnit
AI generated
@test
assert
PHPUnit · Infection · Mutation Testing · CI
Mutation Testing mit Infection
als Qualitätshebel für PHPUnit

100% Code Coverage bedeutet nicht, dass Tests wirklich prüfen. Infection führt automatische Mutationen in den Produktionscode ein und misst, ob PHPUnit-Tests diese Fehler erkennen. Der Mutation Score Index ist die ehrlichste Qualitätsmetrik, die man einer Testsuite anlegen kann.

14 Min. Lesezeit Infection · MSI · Mutanten · CI-Pipeline PHP 8.2+ · PHPUnit 10/11

1. Was Mutation Testing wirklich misst

Mutation Testing beantwortet eine Frage, die Code Coverage nicht beantworten kann: Würden die Tests fehlschlagen, wenn der Produktionscode einen Fehler enthielte? Der Ansatz ist radikal pragmatisch: Ein Tool – in PHP ist das Infection – erzeugt automatisch viele leicht veränderte Versionen des Produktionscodes, sogenannte Mutanten. Jeder Mutant enthält einen kleinen, gezielten Fehler: Ein > wird zu >=, ein return true zu return false, eine Addition zu einer Subtraktion. Dann läuft die gesamte PHPUnit-Testsuite gegen jeden dieser Mutanten.

Schlägt mindestens ein Test fehl, gilt der Mutant als „getötet" – die Tests haben den Fehler erkannt. Läuft die Suite durch, ohne dass ein Test fehlschlägt, hat der Mutant „überlebt". Ein lebender Mutant ist ein Befund: Dieser Fehler im Produktionscode wäre unbemerkt geblieben. Code Coverage misst nur, ob eine Zeile ausgeführt wurde – nicht, ob der Test einen Fehler in dieser Zeile erkennen würde. Der Mutation Score Index (MSI) ist der Prozentsatz getöteter Mutanten und die ehrlichste Qualitätsmetrik, die man einer Testsuite anlegen kann.

Der Einsatz von Mutation Testing verändert, wie man Tests schreibt. Entwickler, die mit Infection arbeiten, beginnen instinktiv, Assertions zu schreiben, die wirklich den Kernlogik-Pfad verifizieren – nicht nur den Happy Path zu durchlaufen und dann eine beliebige Assertion hinzuzufügen, damit der Test „grün" ist. Diese veränderte Denkweise ist oft wertvoller als die eigentlichen MSI-Zahlen.

2. Infection installieren und konfigurieren

Infection wird als Composer-Dev-Dependency installiert: composer require --dev infection/infection. Für die Ausführung benötigt Infection eine PHPUnit-Code-Coverage-Quelle – entweder via Xdebug (XDEBUG_MODE=coverage) oder via PCOV, das deutlich schneller ist. Eine infection.json5-Konfigurationsdatei im Projektroot steuert, welche Verzeichnisse analysiert werden, welche Mutationsoperatoren aktiv sind und welche MSI-Schwellwerte als Mindestanforderung gelten.

Das erste Ausführen zeigt oft erschreckende Ergebnisse: MSI-Werte von 30–50% sind bei Projekten ohne Mutation Testing häufig. Das ist kein Versagen der Entwickler, sondern der Normalzustand, wenn Coverage die einzige Metrik war. Infection zeigt im HTML-Report für jeden lebenden Mutanten genau, welche Codezeile verändert wurde und welche Mutation überlebt hat. Diese Berichte sind der Ausgangspunkt für gezieltes Test-Verbesserung.


// infection.json5 — Infection configuration for a PHP project
{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["src"],
        "excludes": ["src/Infrastructure/Migrations"]
    },
    "mutators": {
        "@default": true,
        "UnwrapArrayFilter": false
    },
    "minMsi": 75,
    "minCoveredMsi": 85,
    "testFramework": "phpunit",
    "testFrameworkOptions": "--testsuite=unit",
    "threads": 4,
    "logs": {
        "text": "var/infection/infection.log",
        "html": "var/infection/index.html",
        "summary": "var/infection/summary.log"
    }
}

3. Die wichtigsten Mutationstypen verstehen

Infection bringt über 60 eingebaute Mutatoren. Die für PHP-Businesslogik relevantesten sind arithmetische Mutatoren (Addition ↔ Subtraktion, Multiplikation ↔ Division), logische Mutatoren (&&||, ! entfernen) und Vergleichs-Mutatoren (>>=, ===!==). Besonders wertvoll sind Return-Mutatoren, die return $value in return null oder return true in return false verwandeln – denn diese Mutanten überleben häufig, wenn Tests den Rückgabewert nicht assertieren.

Die @default-Gruppe aktiviert alle Mutatoren, die für allgemeinen PHP-Code sinnvoll sind. Für spezifische Domänen kann man einzelne Mutatoren deaktivieren. Der UnwrapArrayFilter-Mutator zum Beispiel entfernt array_filter()-Aufrufe – in manchen Codebasen führt das zu tausenden Mutanten in Hilfscode, der nicht die Kernlogik darstellt. Gezieltes Deaktivieren solcher Mutatoren hält den Fokus auf den wirklich relevanten Code.

4. MSI-Score interpretieren und Ziele setzen

Der Mutation Score Index ist der Prozentwert getöteter Mutanten im Verhältnis zur Gesamtzahl der generierten Mutanten. Ein MSI von 70% bedeutet, dass 30% der eingeführten Fehler von der Testsuite unbemerkt geblieben wären. Der Covered MSI hingegen berücksichtigt nur Mutanten in Codezeilen, die von Tests überhaupt ausgeführt werden – er ist immer höher als der MSI und zeigt, wie effektiv die vorhandene Coverage die getesteten Pfade absichert.

Realistische Ziele für Legacy-Projekte beginnen bei 60% MSI und 75% Covered MSI. Für neue Module in greenfield-Projekten sind 80% MSI und 90% Covered MSI erreichbar. Wichtiger als absolute Zahlen ist der Trend: Ein MSI, der bei jedem CI-Lauf steigt oder stabil bleibt, zeigt, dass das Team bewusst mit Testqualität umgeht. Ein fallender MSI signalisiert, dass neue Features ohne ausreichende Absicherung geschrieben werden. Die minMsi-Option in infection.json5 lässt CI-Builds fehlschlagen, wenn der Schwellwert unterschritten wird.

5. Lebende Mutanten analysieren und Tests verbessern

Der HTML-Report von Infection ist das wichtigste Werkzeug für die eigentliche Qualitätsverbesserung. Für jeden lebenden Mutanten zeigt der Report den Original-Code und die Mutation als Diff. Häufige Muster bei lebenden Mutanten: Grenzwertbedingungen, die zwar getestet aber nicht auf den Grenzwert selbst assertiert werden. Rückgabewerte von Methoden, die zwar aufgerufen aber deren Ergebnis nie überprüft wird. Negationen in Bedingungen, die entfernt werden können ohne dass ein Test fehlschlägt.

Die systematische Analyse lebender Mutanten folgt einem klaren Muster: Zunächst identifiziert man die kritischsten überlebenden Mutatoren – solche in Kerngeschäftslogik wie Preisberechnung, Berechtigungsprüfung oder Zustandsübergängen. Für jeden lebenden Mutanten in diesem Bereich schreibt man einen neuen Test, der genau die Bedingung prüft, die der Mutant verändert hat. Dieser Prozess ist iterativ und führt zu Tests, die nicht nur Coverage, sondern echte Fehlererkennungsfähigkeit haben.


<?php
// Example: surviving mutant reveals missing boundary assertion
class DiscountCalculator
{
    /**
     * Returns discount rate for the given order total.
     * Mutant: changed > to >= for the 100.00 threshold — survived!
     * This means no test checked the exact boundary value.
     */
    public function getRate(float $orderTotal): float
    {
        if ($orderTotal > 100.00) {  // Infection mutates to: >= 100.00
            return 0.10;
        }
        return 0.00;
    }
}

// Before: test that lets mutant survive
class BadDiscountTest extends TestCase
{
    public function testLargeOrderGetsDiscount(): void
    {
        $calc = new DiscountCalculator();
        // Only tests far above boundary — mutation > to >= survives
        $this->assertSame(0.10, $calc->getRate(200.00));
    }
}

// After: boundary tests that kill the mutant
class GoodDiscountTest extends TestCase
{
    public function testExactBoundaryGetsNoDiscount(): void
    {
        $calc = new DiscountCalculator();
        $this->assertSame(0.00, $calc->getRate(100.00)); // kills >= mutant
    }

    public function testJustAboveBoundaryGetsDiscount(): void
    {
        $calc = new DiscountCalculator();
        $this->assertSame(0.10, $calc->getRate(100.01)); // kills < mutant
    }
}

6. Mutanten sinnvoll ignorieren ohne Metriken zu verfälschen

Nicht jeder lebende Mutant muss durch einen neuen Test getötet werden. Manche Mutanten sind in Hilfsmethoden, die nur Logging oder Debug-Ausgaben erzeugen – deren Verhalten in Tests zu verifizieren würde Tests produzieren, die mehr Rauschen als Nutzen bringen. Infection bietet zwei Mechanismen, um solche Mutanten auszuschließen: das @infection-ignore-all-Docblock-Attribut für eine gesamte Klasse oder Methode, und die ignoreSourceCodeByRegex-Option in infection.json5.

Die Regel dabei: Ignorierende Mutanten müssen dokumentiert sein. Ein Kommentar, der erklärt, warum ein bestimmter Mutant ignoriert wird, verhindert, dass spätere Entwickler entweder sinnlose Tests für den ignorierten Code schreiben oder denken, dass der MSI bereits optimal ist. Niemals sollte man ganze Verzeichnisse ohne Begründung ausschließen, nur um den MSI zu erhöhen – das untergräbt den Sinn der Metrik.

7. Infection in CI-Pipelines integrieren

Infection in CI zu integrieren bedeutet, zwei Entscheidungen zu treffen: Wann läuft Mutation Testing, und wie streng ist der Schwellwert? Da Mutation Testing deutlich länger dauert als eine normale Test-Suite, empfiehlt sich die Ausführung in einem separaten CI-Job, der nicht im kritischen Pfad jedes Commits liegt. Eine sinnvolle Strategie: Mutation Testing läuft bei jedem Pull-Request gegen die veränderten Dateien (mit --git-diff-filter=AM), nicht gegen die gesamte Codebasis.

Das --git-diff-filter-Flag von Infection ist dabei der entscheidende Hebel: Es filtert die zu analysierenden Dateien auf genau die Dateien, die im aktuellen Branch verändert wurden. Das reduziert die Laufzeit von möglicherweise 20 Minuten für das gesamte Projekt auf 1–3 Minuten für den veränderten Code. Kombiniert mit einem MSI-Schwellwert von 80% für veränderte Dateien stellt das sicher, dass neuer Code immer ausreichend abgesichert ist – ohne dass Legacy-Code, der noch nicht optimiert wurde, die Pipeline blockiert.


# GitHub Actions: Infection for changed files only
# .github/workflows/mutation.yml
name: Mutation Testing

on:
  pull_request:
    branches: [main, develop]

jobs:
  infection:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # required for git-diff-filter

      - name: Setup PHP with PCOV (faster than Xdebug for coverage)
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov
          extensions: pcov

      - run: composer install --no-interaction

      - name: Run PHPUnit with coverage (required by Infection)
        run: vendor/bin/phpunit --coverage-xml=var/infection/coverage/xml
                                --log-junit=var/infection/junit.xml

      - name: Run Infection on changed files only
        run: |
          vendor/bin/infection \
            --git-diff-filter=AM \
            --min-msi=80 \
            --min-covered-msi=90 \
            --coverage=var/infection/coverage \
            --threads=4 \
            --logger-html=var/infection/index.html
        env:
          INFECTION_BADGE_API_KEY: ${ { secrets.INFECTION_BADGE_KEY } }

8. Laufzeitkosten von Mutation Testing kontrollieren

Das größte praktische Hindernis für Mutation Testing ist die Laufzeit. Infection führt die gesamte PHPUnit-Suite für jeden Mutanten aus – bei 500 Mutanten und einer Suite, die 10 Sekunden läuft, sind das 83 Minuten. Drei Strategien senken diese Kosten erheblich: Parallelisierung via --threads, PCOV statt Xdebug für die Coverage-Generierung, und das Einschränken des zu analysierenden Codes auf kritische Verzeichnisse.

Die wirkungsvollste Optimierung ist jedoch die Verwendung von --only-covered: Infection analysiert nur Mutanten in Zeilen, die von mindestens einem Test abgedeckt werden. Das reduziert die Mutanten-Anzahl erheblich, ohne die Qualitätsaussage zu verfälschen – denn Mutanten in nicht abgedecktem Code würden ohnehin überleben und sind durch fehlende Coverage bereits als Problem bekannt. Mit --filter kann man zusätzlich einzelne Testsuiten oder Klassen isolieren, um Mutation Testing schrittweise in bestehende Projekte einzuführen.

9. Coverage vs. MSI: was die Metriken wirklich sagen

Die Unterschiede zwischen Code Coverage und MSI sind fundamental und ergänzen sich, statt zu konkurrieren. Code Coverage sagt: „Diese Zeile wurde von einem Test ausgeführt." Der MSI sagt: „Diese Zeile wurde von einem Test ausgeführt, der auch einen Fehler in dieser Zeile erkennen würde." 100% Coverage mit 40% MSI bedeutet: Alle Zeilen werden durchlaufen, aber fast die Hälfte aller Fehler in diesen Zeilen würde unbemerkt bleiben.

Metrik Was sie misst Schwäche Ergänzung
Line Coverage Zeile ausgeführt? Keine Assertion nötig Zeigt Lücken im Testpfad
Branch Coverage Alle Zweige getestet? Kein Assertionscheck Besser als Line Coverage
Mutation Score Index Fehler in Zeile erkannt? Langsame Ausführung Direktes Qualitätsmaß
Covered MSI MSI nur für covered Code Ignoriert uncovered Code Effizienz der vorhandenen Tests

Die praktische Empfehlung: Coverage als notwendige Bedingung, MSI als hinreichende Bedingung behandeln. Zunächst sicherstellt man hohe Coverage für kritische Pfade, dann optimiert man den MSI für diese abgedeckten Bereiche. Ein Projekt, das beginnt, Coverage als einzige Metrik durch MSI zu ergänzen, stellt meist fest, dass die Tests, die am wenigsten Mutation Testing bestehen, die sind, die auch am wenigsten wirklichen Vertrauen gaben – der MSI macht explizit, was vorher nur ein Bauchgefühl war.

Mironsoft

Testqualität, Mutation Testing und CI-Integration für PHP-Projekte

Testsuite mit echtem Qualitätsmaß?

Wir richten Infection für euer PHP-Projekt ein, analysieren die lebenden Mutanten in eurer Kernlogik und verbessern gezielt die Tests, die bisher Fehler durchlassen würden.

Infection-Setup

Installation, Konfiguration und erste MSI-Baseline für euer PHP-Projekt

Mutanten-Analyse

Systematische Analyse lebender Mutanten in kritischer Geschäftslogik und gezieltes Test-Refactoring

CI-Integration

Infection in GitHub Actions oder GitLab CI mit git-diff-filter für schnelle PR-Checks

10. Zusammenfassung

Mutation Testing mit Infection ist der wirkungsvollste Qualitätshebel für PHPUnit-Testsuiten, den man einsetzen kann, wenn Code Coverage bereits vorhanden ist. Der Mutation Score Index macht sichtbar, was Coverage verbirgt: ob Tests wirklich Fehler erkennen würden. Die Praxis zeigt, dass Projekte mit MSI-getriebener Testenwicklung Tests schreiben, die Grenzwerte, Rückgabewerte und Fehlerbehandlung explizit absichern – statt Code nur zu durchlaufen.

Die Integration in CI ist über --git-diff-filter schlanker als befürchtet: Nur veränderte Dateien werden analysiert, die Laufzeit bleibt kontrollierbar. PCOV statt Xdebug halbiert die Coverage-Generierungszeit. Gezielte Ignores mit dokumentierten Gründen halten den MSI als ehrliche Metrik. Wer mit einem MSI von 60% beginnt und schrittweise auf 80% optimiert, hat am Ende nicht nur bessere Zahlen – er hat eine Testsuite, auf die man sich wirklich verlässt.

Mutation Testing mit Infection — Das Wichtigste auf einen Blick

MSI vs. Coverage

Coverage misst Ausführung, MSI misst Fehlererkennungsfähigkeit. 100% Coverage mit 40% MSI bedeutet: fast die Hälfte aller Fehler wäre unbemerkt geblieben.

CI-Integration

--git-diff-filter=AM analysiert nur veränderte Dateien. Laufzeit 1–3 Min statt 20+ Min für das Gesamtprojekt. PCOV statt Xdebug für schnelle Coverage.

Lebende Mutanten

HTML-Report zeigt Diff jedes lebenden Mutanten. Grenzwerte, Rückgabewerte und Negationen sind die häufigsten Fundstellen. Kritische Logik zuerst optimieren.

Realistische Ziele

Legacy: 60% MSI als Einstieg, 75% als Ziel. Neue Module: 80% MSI, 90% Covered MSI. Trend wichtiger als absolute Zahlen.

11. FAQ: Mutation Testing mit Infection für PHPUnit

1Coverage vs. MSI: was ist der Unterschied?
Coverage: Zeile ausgeführt. MSI: Fehler in der Zeile erkannt. 100% Coverage kann mit 40% MSI einhergehen — fast die Hälfte aller Fehler wäre unbemerkt.
2Wie lange dauert ein Infection-Lauf?
Mit git-diff-filter und PCOV: 1–3 Min für PR-Änderungen. Gesamtprojekt: 10–30 Min. --threads=4 halbiert die Zeit nochmals.
3Welchen MSI-Zielwert anstreben?
Legacy: 60–70% Einstieg, 80% Ziel. Neue Module: 80% MSI, 90% Covered MSI. Trend wichtiger als absolute Zahlen.
4Mutanten ignorieren ohne MSI-Verfälschung?
@infection-ignore-all im Docblock oder ignoreSourceCodeByRegex in infection.json5. Ignorierte Mutanten werden aus der Berechnung ausgeschlossen. Begründung dokumentieren.
5Wichtigste Mutatoren für Businesslogik?
Vergleichs-Mutatoren (> zu >=), Return-Mutatoren (true zu false), logische Mutatoren (&& zu ||). Diese überleben am häufigsten in schwach getesteter Businesslogik.
6Coverage vor Infection aktivieren?
Ja, Infection braucht Coverage-Daten. PCOV statt Xdebug empfohlen — deutlich schneller. PHPUnit mit --coverage-xml ausführen bevor Infection startet.
7Infection für Integrationstests?
Technisch möglich, aber nicht empfohlen. Integrationstests erhöhen die Laufzeit exponentiell. Infection am effektivsten für schnelle Unit-Tests der Businesslogik.
8Was ist Covered MSI?
MSI nur für Zeilen mit Coverage. Immer höher als Gesamt-MSI. Zeigt wie effektiv vorhandene Tests Fehler in abgedecktem Code erkennen.
9PHP 8 Features unterstützt?
Ja, Infection unterstützt PHP 8.0–8.4 inklusive Readonly Properties, Enums, Match-Expressions und Union Types. Spezifische Mutatoren in neueren Versionen.
10Warum überlebt ein Mutant?
HTML-Report zeigt exakten Diff pro Mutant. Häufigste Ursachen: fehlende Boundary-Assertions, nicht geprüfte Rückgabewerte, fehlende Negations-Tests.