Signal statt Vanity-Metrik
80% Line-Coverage klingt gut. Aber Line-Coverage sagt nichts darüber aus, ob die richtigen Fälle getestet werden. Ein Zweig, der bei einem bestimmten Eingabewert ausgeführt wird, zählt als abgedeckt – egal ob der andere Zweig je getestet wird. Coverage ist ein Werkzeug, kein Ziel. Wer es als Vanity-Metrik behandelt, optimiert die Zahl statt die Qualität.
Inhaltsverzeichnis
- 1. Was Coverage wirklich misst – und was nicht
- 2. Line-Coverage: die schnellste und schwächste Metrik
- 3. Branch-Coverage: Verzweigungen gezielt prüfen
- 4. Path-Coverage und Mutation-Testing
- 5. PHPUnit-Coverage-Berichte generieren und lesen
- 6. Coverage-Ziele sinnvoll setzen
- 7. Die häufigsten Coverage-Fallen
- 8. Coverage-Metriken im Vergleich
- 9. Zusammenfassung
- 10. FAQ
1. Was Coverage wirklich misst – und was nicht
Coverage misst, welche Teile des Produktionscodes während der Testausführung ausgeführt wurden. Sie sagt nichts darüber aus, ob diese Ausführung zu korrekten Ergebnissen geführt hat, ob alle Eingabevarianten geprüft wurden, oder ob die Tests sinnvolle Assertions enthalten. Ein Test, der eine Methode aufruft aber nichts assertiert, erhöht die Coverage auf 100% – und gibt trotzdem null Sicherheit.
Diese Einschränkung ist fundamental und wird in der Praxis systematisch unterschätzt. Teams, die Coverage-Ziele als KPI verwenden, schaffen unweigerlich Anreize, diese Ziele durch schwache Tests zu erreichen. Das Ergebnis: hohe Coverage-Zahlen, fragile Test-Suite, falsche Sicherheit. Coverage ist am nützlichsten als Werkzeug, das zeigt, was noch nicht getestet ist – nicht als Beweis dafür, dass Tests gut sind. Rote Bereiche im Coverage-Bericht sind valide Hinweise auf fehlende Tests. Grüne Bereiche garantieren nur, dass der Code ausgeführt wurde.
Es gibt zwei wichtige Treiber für eine sinnvolle Coverage-Analyse: Erstens, Coverage als Lückenfinder nutzen – regelmäßig den Bericht nach nicht-abgedeckten Pfaden durchsehen, besonders in Business-Logik-Klassen und Fehlerbehandlungscode. Zweitens, Coverage in Kombination mit Branch-Coverage auswerten, um sicherzustellen, dass Verzweigungen in beide Richtungen getestet sind. Nur diese Kombination gibt ein halbwegs vollständiges Bild.
2. Line-Coverage: die schnellste und schwächste Metrik
Line-Coverage zählt, welche Zeilen des Quellcodes mindestens einmal ausgeführt wurden. Eine Zeile gilt als abgedeckt, sobald ein Test sie ausführt – unabhängig davon, in welchem Kontext oder mit welchen Eingabewerten. Das macht Line-Coverage zur einfachsten zu erreichenden Metrik. Ein Test, der den Happy Path einer Methode ausführt, deckt in der Regel alle Zeilen ab – auch Zeilen, die nur im Fehlerfall relevant sind, wenn sie in denselben physischen Zeilen stehen wie Erfolgs-Code.
Der blinde Fleck von Line-Coverage: Jede Zeile, die eine Bedingung enthält, kann von der "wahren" Seite aus abgedeckt sein, ohne dass die "falsche" Seite je ausgeführt wurde. if ($price > 0) { return $price; } return 0; – wenn alle Tests positive Preise übergeben, ist Line-Coverage 100%. Der Zweig für $price ≤ 0 wurde nie ausgeführt. Branch-Coverage würde das sichtbar machen.
<?php
declare(strict_types=1);
namespace App\Domain;
/**
* Calculates discount based on order amount and customer tier.
* All branches must be tested to detect hidden logic errors.
*/
final class DiscountCalculator
{
/**
* Returns the discount percentage for a given order amount and tier.
* Line coverage: one test with $amount=200, tier='gold' covers all lines.
* Branch coverage: requires tests for all combinations of conditions.
*/
public function calculate(float $amount, string $tier): float
{
if ($amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive');
}
// Branch 1: premium tier — amount threshold matters
if ($tier === 'premium') {
return $amount >= 500 ? 0.20 : 0.10;
}
// Branch 2: gold tier — flat discount
if ($tier === 'gold') {
return 0.05;
}
// Branch 3: default — no discount
return 0.0;
}
}
// Test that achieves 100% LINE coverage but misses branches:
final class WeakCoverageTest extends TestCase
{
/** @test */
public function calculates_discount(): void
{
$calc = new DiscountCalculator();
// Only tests premium tier with amount >= 500 — misses 4 other branches
$this->assertSame(0.20, $calc->calculate(600, 'premium'));
}
// Line coverage: 100% — Branch coverage: ~30%
}
// Test that achieves 100% BRANCH coverage:
final class FullBranchCoverageTest extends TestCase
{
/**
* @test
* @dataProvider discountProvider
*/
public function calculates_discount_for_all_cases(
float $amount, string $tier, float $expected
): void {
$this->assertSame($expected, (new DiscountCalculator())->calculate($amount, $tier));
}
public static function discountProvider(): array
{
return [
'premium_high' => [600, 'premium', 0.20],
'premium_low' => [200, 'premium', 0.10],
'gold_any' => [100, 'gold', 0.05],
'default_tier' => [100, 'standard', 0.0],
];
}
/** @test */
public function throws_on_non_positive_amount(): void
{
$this->expectException(\InvalidArgumentException::class);
(new DiscountCalculator())->calculate(0, 'gold');
}
}
3. Branch-Coverage: Verzweigungen gezielt prüfen
Branch-Coverage (auch Decision-Coverage genannt) zählt, ob jede Verzweigung in beide mögliche Richtungen ausgeführt wurde. Bei einem if-else gibt es zwei Zweige: wahr und falsch. Bei einem match mit fünf Armen gibt es fünf Zweige. Branch-Coverage ist abgedeckt, wenn alle diese Zweige mindestens einmal ausgeführt wurden. Das ist eine erheblich stärkere Garantie als Line-Coverage.
In PHPUnit wird Branch-Coverage über Xdebug (mit aktiviertem xdebug.mode=coverage) oder PCOV erfasst. Der HTML-Coverage-Report zeigt Zweige mit Farbmarkierungen: grün für abgedeckte Zweige, rot für nicht abgedeckte. Im Text-Report erscheinen Branch-Coverage-Werte als separate Spalte. Die Konfiguration in phpunit.xml aktiviert Coverage für bestimmte Verzeichnisse über <include> unter <source>.
<!-- phpunit.xml — Coverage configuration for PHPUnit 10/11 -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<!-- Source paths for coverage analysis — excludes generated code -->
<source>
<include>
<directory suffix=".php">src</directory>
</include>
<exclude>
<directory>src/Generated</directory>
<file>src/Kernel.php</file>
</exclude>
</source>
<!-- Coverage output formats -->
<coverage>
<report>
<html outputDirectory="coverage/html" lowUpperBound="50" highLowerBound="90"/>
<clover outputFile="coverage/clover.xml"/>
<text outputFile="coverage/coverage.txt" showUncoveredFiles="true"/>
</report>
</coverage>
</phpunit>
<!-- Run with: vendor/bin/phpunit --coverage-html coverage/html -->
<!-- Branch coverage requires Xdebug: XDEBUG_MODE=coverage vendor/bin/phpunit -->
4. Path-Coverage und Mutation-Testing
Path-Coverage ist die stärkste und teuerste Coverage-Metrik: Sie zählt, ob alle möglichen Ausführungspfade durch eine Funktion getestet wurden. Bei einer Funktion mit drei unabhängigen Bedingungen gibt es acht mögliche Pfade (2³). Path-Coverage zu 100% zu erreichen ist für komplexe Methoden praktisch unmöglich – aber das Konzept ist wertvoll, weil es verdeutlicht, wie stark Line-Coverage und Branch-Coverage unterbestimmt sind.
Mutation-Testing (mit Infection für PHP) ist das wirksamste Werkzeug, um die Qualität von Tests zu messen. Infection modifiziert den Produktionscode minimal (ändert > zu >=, entfernt Rückgabewerte, kehrt Bedingungen um) und prüft, ob die Tests diese Mutationen erkennen. Tests, die keine Mutationen erkennen, sind schwach – sie decken den Code ab, prüfen ihn aber nicht wirklich. Der Mutation-Score-Indicator (MSI) ist eine ehrlichere Qualitätsmetrik als Line-Coverage.
5. PHPUnit-Coverage-Berichte generieren und lesen
Der HTML-Coverage-Report ist das leistungsfähigste Werkzeug zur Analyse. Er zeigt auf Datei-, Klassen- und Methodenebene, welche Zeilen und Zweige abgedeckt sind. Farben: Grün heißt vollständig abgedeckt, gelb heißt teilweise abgedeckt (einige Zweige fehlen), rot heißt nicht abgedeckt. Besonders wertvoll ist die Suche nach roten Bereichen in kritischen Geschäftslogik-Klassen – diese sollten priorisiert getestet werden.
Der Clover-XML-Report ist für CI-Integration gedacht: Tools wie SonarQube, Codecov und Coveralls konsumieren dieses Format. Mit --coverage-clover coverage/clover.xml wird er generiert. In CI-Pipelines kann man mit --coverage-filter die Coverage auf geänderte Dateien beschränken, um Laufzeit zu sparen. Mit PHPUnit 10+ lassen sich Mindest-Coverage-Schwellenwerte in phpunit.xml definieren, die den Test-Run bei Unterschreitung scheitern lassen.
6. Coverage-Ziele sinnvoll setzen
Coverage-Ziele als absolute Prozentzahlen sind problematisch: Sie erzeugen Anreize, schwache Tests zu schreiben. Sinnvoller ist es, Coverage differenziert nach Code-Schicht zu betrachten. Domain-Logik und Business-Rules sollten hohe Branch-Coverage anstreben (80–90%). Framework-Boilerplate, Konfiguration und generierter Code sollten von der Coverage-Analyse ausgeschlossen werden. Das Erreichen von 60% Branch-Coverage auf echtem Produktionscode ist wertvoller als 95% Line-Coverage auf einem Mix aus echtem Code und Boilerplate.
Eine praxistaugliche Coverage-Strategie: Coverage-Schwellenwerte pro Verzeichnis oder Modul definieren, nicht global. Neue Code-Pfade müssen Tests haben, bevor sie gemergt werden (Coverage-Ratchet). Bestehende Lücken werden mit einem Ticket erfasst und priorisiert. Der Coverage-Bericht wird wöchentlich analysiert – nicht als Metrik, sondern als Lückenfinder.
| Coverage-Typ | Misst | Stärke | Blinder Fleck |
|---|---|---|---|
| Line-Coverage | Ausgeführte Zeilen | Schnell, einfach zu verstehen | Ignoriert Verzweigungen |
| Branch-Coverage | Beide Seiten jeder Verzweigung | Entdeckt ungetestete Pfade | Kombinationen von Zweigen |
| Path-Coverage | Alle Ausführungspfade | Vollständigste Garantie | Exponentiell viele Pfade |
| Mutation-Score | Erkannte Code-Mutationen | Misst Test-Qualität direkt | Laufzeit-intensiv |
| Statement-Coverage | Ausgeführte Statements | Feiner als Line-Coverage | Ähnliche Schwächen wie Line |
7. Die häufigsten Coverage-Fallen
Die erste und häufigste Coverage-Falle: Tests ohne Assertions. Ein Test, der eine Methode aufruft und dann ohne $this->assert...() endet, erhöht die Coverage ohne jede Qualitätsgarantie. PHPUnit 10 warnt bei Tests ohne Assertions, aber die Warnung wird oft ignoriert. Die Lösung: $this->addToAssertionCount(1) explizit zählen wenn Tests absichtlich keine klassische Assertion enthalten (z.B. bei Exception-Tests mit expectException).
Die zweite Falle: Getter-Spam. Viele Teams erreichen hohe Coverage, indem sie Getter-Methoden aufrufen und das Ergebnis assertieren – für Felder, die gar keine Business-Logik enthalten. Das erhöht die Coverage-Zahl, testet aber keine Fachlichkeit. Getter sind kein Test-Target. Business-Logik ist Test-Target. Die dritte Falle: Coverage-Ausschluss von schwierigem Code. @codeCoverageIgnore an Klassen oder Methoden zu schreiben, weil diese schwer zu testen sind, ist eine Kapitulation vor dem eigentlichen Problem. Schwer testbarer Code ist oft ein Design-Signal: Die Klasse hat zu viele Abhängigkeiten oder Verantwortlichkeiten.
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Domain\DiscountCalculator;
/**
* Demonstrates the difference between weak and strong coverage tests.
*/
final class CoverageQualityTest extends TestCase
{
// TRAP 1: Test without assertion — increases coverage, tests nothing
/** @test */
public function weak_no_assertion(): void
{
$calc = new DiscountCalculator();
$calc->calculate(100, 'gold'); // coverage: yes, assertion: NONE
}
// TRAP 2: Only happy path — 100% line coverage, 30% branch coverage
/** @test */
public function weak_happy_path_only(): void
{
$calc = new DiscountCalculator();
$result = $calc->calculate(600, 'premium');
$this->assertSame(0.20, $result);
// Misses: premium <500, gold, default, negative amount
}
// STRONG: All branches explicitly covered
/** @test */
public function strong_premium_below_threshold(): void
{
$this->assertSame(0.10, (new DiscountCalculator())->calculate(200, 'premium'));
}
/** @test */
public function strong_gold_tier_flat_discount(): void
{
$this->assertSame(0.05, (new DiscountCalculator())->calculate(1000, 'gold'));
}
/** @test */
public function strong_unknown_tier_returns_zero(): void
{
$this->assertSame(0.0, (new DiscountCalculator())->calculate(100, 'bronze'));
}
/** @test */
public function strong_zero_amount_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Amount must be positive');
(new DiscountCalculator())->calculate(0, 'gold');
}
/** @test */
public function strong_negative_amount_throws(): void
{
$this->expectException(\InvalidArgumentException::class);
(new DiscountCalculator())->calculate(-50, 'premium');
}
}
8. Coverage-Metriken im Vergleich
Eine differenzierte Betrachtung der Coverage-Metriken hilft dabei, die richtige Metrik für den richtigen Zweck einzusetzen. Nicht alle Metriken sind für alle Code-Schichten gleich geeignet.
9. Zusammenfassung
PHPUnit-Coverage richtig zu lesen bedeutet: Line-Coverage als Lückenfinder, nicht als Qualitätsbeweis. Branch-Coverage als stärkere Garantie für verzweigten Code. Mutation-Testing als ehrlichste Metrik für Test-Qualität. Coverage-Ziele differenziert nach Code-Schicht – nicht als globale Prozentzahl. Den Coverage-Bericht wöchentlich als Lückenfinder nutzen, nicht als KPI verwalten.
Der wichtigste Grundsatz: Coverage ist ein Mittel, kein Ziel. Wer hohe Coverage als Ziel definiert, schreibt schwache Tests. Wer Coverage als Werkzeug nutzt, um Lücken zu finden und zu schließen, baut eine Test-Suite auf, die echte Sicherheit gibt.
PHPUnit Coverage — Das Wichtigste auf einen Blick
Line-Coverage ≠ Qualität
100% Line-Coverage mit Tests ohne Assertions oder nur Happy-Path ist wertlos. Coverage misst Ausführung, nicht Korrektheit.
Branch-Coverage priorisieren
Alle Zweige in beide Richtungen testen. XDEBUG_MODE=coverage aktivieren. HTML-Report auf rote Zweige prüfen.
Mutation-Testing
Infection für PHP nutzen. MSI (Mutation Score Indicator) ist eine ehrlichere Metrik als Line-Coverage.
Coverage als Lückenfinder
Rote Bereiche im Report sind valide Hinweise. Coverage-Ausschlüsse durch @codeCoverageIgnore sind meistens ein Design-Signal.
Mironsoft
PHP-Entwicklung, Test-Qualitätssicherung und Coverage-Analyse
Coverage, die echte Qualität zeigt statt Zahlen aufhübscht?
Wir analysieren bestehende Test-Suites auf echte Branch-Coverage-Lücken, richten Mutation-Testing ein und definieren realistische Coverage-Strategien, die echte Sicherheit geben.
Coverage-Analyse
Auswertung von Line- und Branch-Coverage mit Identifikation kritischer Lücken
Mutation-Testing
Infection-Setup und MSI-Baseline für ehrliche Test-Qualitätsmessung
CI-Integration
Coverage-Schwellenwerte in CI-Pipeline einrichten und Coverage-Ratchet aufbauen