@test
assert
PHPUnit · Code-Review · Test-Qualität · Teams
PHPUnit Test-Reviews in Teams
Was an Tests wirklich geprüft werden sollte

Grüne Tests bedeuten nicht, dass Code sicher ist. In vielen Teams werden Tests im Review nur oberflächlich begutachtet – ob sie laufen, ob Coverage-Zahlen stimmen. Dabei sind es ganz andere Kriterien, die entscheiden, ob ein PHPUnit-Test echten Schutz vor Regressionen bietet oder nur Sicherheit vortäuscht.

12 Min. Lesezeit Assertions · Isolierung · Mutation Testing · Test-Lesbarkeit PHPUnit 10/11 · PHP 8.x

1. Warum Test-Reviews oft scheitern

In den meisten Entwicklungsteams wird Code-Review als Pflicht verstanden – für Produktionscode. Tests werden dabei häufig anders behandelt: Sie müssen grün sein, die Coverage-Schwelle erfüllen, und dann ist der Review abgehakt. Diese Haltung ist nachvollziehbar, weil Tests technisch nicht ausgeführten Code erzeugen. Sie ist aber gefährlich, weil schlechte Tests eine falsche Sicherheit aufbauen, die später in der Produktion sichtbar wird.

Der eigentliche Schaden entsteht nicht sofort. Ein Test, der das falsche Verhalten prüft, gibt beim CI-Durchlauf Grün und erzeugt Coverage. Monate später ändert ein Entwickler die Business-Logik, alle Tests bleiben grün, weil keiner dieser Tests das relevante Verhalten wirklich abgesichert hat – und der Bug erreicht die Produktion. An diesem Punkt ist der Zusammenhang zwischen fehlerhaftem Test-Review und Produktionsfehler schwer zurückzuverfolgen.

Ein guter Test-Review folgt anderen Kriterien als ein Produktionscode-Review. Statt Typsicherheit und Architektur steht im Zentrum: Prüft dieser Test das Verhalten, das für den Aufrufer wichtig ist? Würde der Test fehlschlagen, wenn die Implementation falsch wäre? Diese Fragen erfordern Kontext – und sie sind die Grundlage des folgenden Leitfadens.

2. Assertions: Qualität statt Quantität

Die häufigste Review-Frage zu Assertions ist die falsche: Wie viele Assertions hat der Test? Die richtige Frage lautet: Prüft die Assertion das Verhalten, das nach außen sichtbar ist, oder prüft sie ein internes Implementationsdetail? assertTrue($result) auf einem Boolean-Rückgabewert ohne Aussage darüber, was true bedeutet, ist inhaltlich leer. assertSame(42.50, $cart->getTotal()) hingegen macht eine spezifische Verhaltensaussage.

Im Review muss jede Assertion auf ihre Aussagekraft geprüft werden. Eine Assertion wie assertNotNull($result) schlägt nur fehl, wenn null zurückgegeben wird – nicht aber, wenn ein falscher Wert zurückgegeben wird. assertSame ist in den meisten Fällen strikter und besser als assertEquals, weil es zusätzlich den Typ prüft. assertInstanceOf prüft nur die Klasse, nicht den Inhalt des Objekts. Im Review sollte immer gefragt werden: Was würde passieren, wenn der Code die falsche Antwort gibt – würde diese Assertion es bemerken?


<?php

declare(strict_types=1);

namespace Tests\Unit\Domain\Cart;

use App\Domain\Cart\CartCalculator;
use App\Domain\Cart\CartItem;
use Money\Money;
use PHPUnit\Framework\TestCase;

/**
 * Test for CartCalculator — verifies business rules, not implementation details.
 */
final class CartCalculatorTest extends TestCase
{
    private CartCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new CartCalculator();
    }

    /** @test */
    public function it_applies_ten_percent_discount_when_total_exceeds_one_hundred_euros(): void
    {
        $items = [
            new CartItem('Book', Money::EUR(6000)),
            new CartItem('Pen', Money::EUR(5000)),
        ];

        $total = $this->calculator->calculate($items);

        // Specific behavioral assertion: discount applied, exact amount expected
        self::assertEquals(Money::EUR(9900), $total);
    }

    /** @test */
    public function it_does_not_apply_discount_when_total_is_below_threshold(): void
    {
        $items = [new CartItem('Eraser', Money::EUR(3000))];

        $total = $this->calculator->calculate($items);

        self::assertEquals(Money::EUR(3000), $total);
    }
}

3. Testnamen als lebendige Dokumentation

Testmethoden sind Spezifikationsdokumente. Ihr Name sollte in einem einzigen Satz beschreiben, welches Verhalten unter welchen Bedingungen erwartet wird. Der verbreitete Stil testCalculate() oder test_returns_true() sagt nichts aus. Wenn dieser Test fehlschlägt, weiß niemand, welches Verhalten defekt ist. Der bevorzugte Stil folgt dem Schema it_[beschreibt_verhalten]_when_[bedingung]().

Im Review sollte der Reviewer in der Lage sein, allein aus dem Testnamen zu verstehen, was getestet wird – ohne den Testcode zu lesen. Ist das nicht möglich, ist der Name unzureichend. Gute Testnamen entstehen, wenn der Entwickler das Verhalten in natürlicher Sprache formuliert: "Es wendet 10 % Rabatt an, wenn die Bestellsumme 100 Euro übersteigt" – und das direkt als Methodenname in snake_case übersetzt. Diese Disziplin zwingt außerdem dazu, tatsächlich isolierte Szenarien zu testen statt alles in einer Methode zu prüfen.

4. Isolierung und Abhängigkeiten prüfen

Ein Unit-Test, der eine Datenbankverbindung öffnet, eine HTTP-Anfrage stellt oder Systemzeit liest, ist kein Unit-Test mehr. Im Review muss geprüft werden: Welche externen Abhängigkeiten hat das zu testende System, und sind sie vollständig durch Test-Doubles (Stubs, Mocks, Fakes) ersetzt? Wenn eine Klasse nicht testbar ist ohne Seiteneffekte, ist das ein Signal, dass die Produktionsarchitektur überarbeitet werden muss – nicht, dass der Test komplizierter werden darf.

Isolierung ist auch eine Frage der Reihenfolge. Tests, die von der Ausführungsreihenfolge abhängen, sind inherent fragil. Im Review sollte gefragt werden: Schlägt dieser Test fehl, wenn er allein ausgeführt wird? Schlägt er fehl, wenn er in einer anderen Reihenfolge läuft? PHPUnit-Attributes wie #[Depends] können genutzt werden, um explizite Abhängigkeiten zu deklarieren – aber meistens sind solche Abhängigkeiten ein Zeichen für fehlende Isolierung.

5. Grenzwerte und negative Szenarien

Tests, die nur den Happy Path abdecken, sind das häufigste Qualitätsproblem in PHP-Projekten. Im Review muss aktiv nachgefragt werden: Wo sind die Grenzwerte dieser Funktion, und sind sie getestet? Leere Listen, Nullwerte, maximale Werte, negative Zahlen, leere Strings – diese Eingaben sind häufig die Quelle von Produktionsfehlern. Ein Rabattrechner, der für 0 Artikel eine Division durch Null erzeugt, wäre mit einem einzigen Edge-Case-Test gefunden worden.

Negative Szenarien bedeuten nicht nur Exceptions prüfen. Sie bedeuten auch: Was passiert, wenn der Service nicht verfügbar ist? Was passiert bei Race Conditions? Was passiert, wenn eine externe API ein leeres Array statt einer Liste zurückgibt? Im Review sollte der Reviewer für jede Test-Klasse prüfen, ob die Ausnahme- und Grenzszenarios mit der gleichen Sorgfalt abgedeckt sind wie die Standardfälle. PHPUnit bietet assertThrows und Data Providers, um diese Szenarien systematisch abzudecken.


<?php

declare(strict_types=1);

namespace Tests\Unit\Domain\Pricing;

use App\Domain\Pricing\DiscountCalculator;
use App\Domain\Pricing\Exception\InvalidQuantityException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
 * Edge-case coverage for DiscountCalculator.
 */
final class DiscountCalculatorEdgeCasesTest extends TestCase
{
    /** @return array<string, array{int, float}> */
    public static function discountBoundaryProvider(): array
    {
        return [
            'zero items returns zero' => [0, 0.0],
            'exactly at threshold returns min discount' => [10, 5.0],
            'above threshold returns max discount' => [100, 20.0],
        ];
    }

    #[DataProvider('discountBoundaryProvider')]
    public function it_calculates_correct_discount_for_boundary_quantities(
        int $quantity,
        float $expectedDiscount
    ): void {
        $calculator = new DiscountCalculator();
        self::assertSame($expectedDiscount, $calculator->calculate($quantity));
    }

    public function it_throws_for_negative_quantity(): void
    {
        $this->expectException(InvalidQuantityException::class);
        $this->expectExceptionMessage('Quantity must not be negative');

        (new DiscountCalculator())->calculate(-1);
    }
}

6. Mock-Missbrauch erkennen

Mocks sind das mächtigste und am häufigsten missbrauchte Werkzeug in PHPUnit. Im Review sollte gezielt auf zwei Anti-Patterns geachtet werden: Erstens, Tests, die so viele Mocks aufsetzen, dass der eigentliche Produktionscode kaum noch ausgeführt wird – der Test prüft dann nur, ob Methoden in bestimmter Reihenfolge aufgerufen werden, nicht aber ob das Ergebnis korrekt ist. Zweitens, Tests, die interne Methoden der zu testenden Klasse mocken. Das führt dazu, dass Refactorings immer auch Test-Änderungen erzwingen, obwohl das beobachtbare Verhalten gleich bleibt.

Die Faustregel im Review: Mehr als drei Mocks in einem einzelnen Unit-Test sind ein Warnsignal. In solchen Fällen sollte gefragt werden, ob die Klasse zu viele Verantwortlichkeiten trägt, oder ob ein Integration-Test besser geeignet wäre. Mocks sollten Grenzen nach außen isolieren: HTTP-Clients, Datenbank-Repositories, E-Mail-Dienste. Sie sollten nicht verwendet werden, um internes Verhalten zu simulieren oder zu umgehen.

7. Mutation Testing als objektives Maß

Code-Coverage ist eine notorisch unzuverlässige Metrik für Testqualität. 100 % Coverage bedeutet, dass jede Zeile ausgeführt wurde – nicht, dass das korrekte Verhalten geprüft wurde. Mutation Testing mit Infection löst dieses Problem: Das Tool verändert den Produktionscode automatisch (mutiert ihn) und prüft, ob die Testsuite diese Mutation erkennt und einen Fehler meldet. Ein Test, der eine Mutation überlebt, ist ein Test, der das betreffende Verhalten nicht wirklich absichert.

Im Team-Kontext eignet sich Mutation Testing am besten für kritische Business-Logik-Klassen. Das Einbinden in den CI-Prozess mit einem Mindest-Mutation-Score (z.B. 80 %) gibt dem Review-Prozess eine objektive Basis: Wenn der Mutation Score unter die Schwelle fällt, müssen zusätzliche Tests geschrieben werden. Dieses Vorgehen verhindert, dass Coverage-Gaming – also das Schreiben von Tests, die Coverage erzeugen aber nichts wirklich prüfen – unentdeckt bleibt.


<?php

// infection.json5 — Mutation Testing configuration for PHPUnit projects
{
    "timeout": 10,
    "source": {
        "directories": ["src/Domain"],
        "excludes": ["src/Domain/*/Exception"]
    },
    "logs": {
        "text": "infection.log",
        "html": "infection.html",
        "json": "infection.json"
    },
    "minMsi": 80,
    "minCoveredMsi": 85,
    "mutators": {
        "@default": true,
        "PublicVisibility": false,
        "ProtectedVisibility": false
    },
    "phpUnit": {
        "configDir": "."
    }
}

// Run: vendor/bin/infection --threads=4 --show-mutations
// CI: exit code non-zero when minMsi not reached — blocks merge

8. Checkliste für den Test-Review-Prozess

Ein strukturierter Test-Review-Prozess beginnt mit einer Checkliste, die im Team als Standard akzeptiert wird. Die wichtigsten Punkte: Ist der Testname eine vollständige Verhaltensaussage? Prüft jede Assertion das beobachtbare Verhalten, nicht das Implementationsdetail? Sind externe Abhängigkeiten vollständig durch Test-Doubles ersetzt? Gibt es Tests für Grenzwerte, leere Eingaben und Fehlerfälle? Ist jeder Test unabhängig von anderen Tests lauffähig?

Im praktischen Review-Prozess bedeutet das: Der Reviewer geht nicht nur durch den Diff, sondern führt lokal vendor/bin/phpunit --filter=NachDemNeuenTest aus, um zu sehen, ob der Test isoliert läuft. Er liest den Testnamen und formuliert in einem Satz, was er erwartet – dann prüft er, ob die Assertions genau das sicherstellen. Er sucht aktiv nach fehlenden Szenarien, nicht nur nach Syntaxproblemen. Diese Haltung kostet mehr Zeit, spart aber in der Summe mehr Debugging-Zeit als sie kostet.

9. Review-Kriterien im Vergleich

Nicht alle Review-Kriterien sind gleich wichtig. Die folgende Tabelle zeigt, welche Eigenschaften einen Test wirklich wertvoller machen und welche häufigen Metriken dabei in die Irre führen können.

Kriterium Schlechtes Signal Gutes Signal Wichtigkeit
Testname testCalculate() it_applies_discount_when_total_exceeds_threshold() Hoch
Assertion assertNotNull($result) assertSame(9900, $result->getAmount()) Sehr hoch
Isolierung Datenbankaufruf im Unit-Test Repository als Stub Sehr hoch
Coverage 90 % ohne Grenzwerte 70 % mit Edge Cases + Mutation Score 80 % Mittel
Mock-Tiefe 5+ Mocks pro Test Max. 2–3 externe Grenzen gemockt Hoch

Der entscheidende Punkt: Mutation Testing ist die objektivste Metrik, weil sie nicht durch Teststruktur, sondern durch tatsächliche Verhaltensabsicherung bewertet. Teams, die Infection in ihren CI-Prozess integrieren, berichten regelmäßig, dass Coverage-Zahlen und Mutation Scores kaum korrelieren – ein starker Hinweis darauf, dass Coverage allein als Review-Kriterium unzureichend ist.

Mironsoft

PHPUnit-Beratung, Test-Strategie und Code-Review für PHP-Teams

Test-Reviews, die wirklich Qualität sichern?

Wir analysieren bestehende PHPUnit-Testsuiten, identifizieren strukturelle Qualitätsprobleme und etablieren Review-Prozesse, die echte Verhaltensabsicherung statt Coverage-Zahlen in den Mittelpunkt stellen.

Test-Audit

Bestehende Tests auf Assertion-Qualität, Isolierung und Grenzwert-Abdeckung prüfen

Mutation Testing

Infection in CI integrieren und Mutation Score als objektive Qualitätsschranke einführen

Review-Prozess

Team-spezifische Checklisten und Review-Standards für PHPUnit-Tests etablieren

10. Zusammenfassung

Test-Reviews in PHP-Teams werden dann wirksam, wenn sie nicht auf Coverage-Zahlen und grüne CI-Läufe reduziert werden. Die entscheidenden Kriterien sind: Testnamen, die vollständige Verhaltensaussagen machen; Assertions, die das beobachtbare Ergebnis spezifisch prüfen; vollständige Isolierung externer Abhängigkeiten; systematische Grenzwert- und Fehlerszenarientests; und Mutation Testing als objektives Qualitätsmaß.

Der organisatorische Hebel liegt in einer gemeinsam vereinbarten Checkliste, die im Review-Prozess verbindlich ist. Teams, die diesen Standard einführen, berichten nach wenigen Monaten von deutlich weniger Regressions-Bugs – weil ihre Tests nicht nur Code ausführen, sondern tatsächlich das Verhalten absichern, auf das der nächste Entwickler und der nächste Deploy zählen.

PHPUnit Test-Reviews — Das Wichtigste auf einen Blick

Assertions prüfen

Jede Assertion muss das beobachtbare Verhalten spezifisch absichern. assertSame ist strikter als assertEquals. assertNotNull ist fast immer unzureichend.

Testnamen als Spezifikation

Der Name muss Verhalten und Bedingung beschreiben. Wenn der Reviewer aus dem Namen nicht versteht, was getestet wird, ist der Name unzureichend.

Mutation Score statt Coverage

Infection als CI-Gate einsetzen. Mutation Score 80 %+ ist ein besseres Qualitätsmerkmal als Code-Coverage-Prozente allein.

Grenzwerte aktiv suchen

Im Review aktiv fragen: Wo sind die Grenzen dieser Funktion? Leere Listen, Null, negative Zahlen, maximale Werte – jeder Grenzwert ist ein potenzieller Produktionsbug.

11. FAQ: PHPUnit Test-Reviews in Teams

1Was ist der häufigste Fehler bei Test-Reviews?
Coverage-Zahlen und grüne CI-Läufe als Qualitätsmerkmal akzeptieren, ohne Assertion-Qualität und Grenzwertabdeckung zu prüfen. Ein Test kann 100 % Coverage erzeugen und trotzdem nichts absichern.
2Wie erkenne ich wertlose Tests?
Schlechte Testnamen, schwache Assertions (assertNotNull, assertTrue ohne Kontext) und mehr als drei Mocks in einem Unit-Test sind starke Warnsignale für mangelnde Testqualität.
3Was ist Mutation Testing?
Infection verändert Produktionscode automatisch und prüft, ob Tests die Änderung erkennen. Überlebt eine Mutation alle Tests, sichert kein Test dieses Verhalten ab – objektiver als Coverage.
4Wie viele Assertions pro Test?
Keine feste Zahl. Eine präzise Assertion ist wertvoller als zehn schwache. Mehrere Assertions sind sinnvoll, wenn sie verschiedene Aspekte desselben Verhaltens prüfen.
5Wann ist ein Mock zu viel?
Mehr als drei Mocks in einem Unit-Test signalisiert zu viele Abhängigkeiten oder dass ein Integration-Test besser passt. Mocks sollten nur externe Grenzen isolieren.
6Wie formuliere ich gute Testnamen?
Schema: it_[verhalten]_when_[bedingung](). Der Name soll ohne Lesen des Codes verständlich sein – wie eine Spezifikation in einem Satz.
7Soll man fremde Tests reviewen?
Ja. Test-Code ist Produktionscode für die Testsuite. Fehler kumulieren und erzeugen falsche Sicherheit – genauso gefährlich wie Bugs im Produktionscode.
8Was prüfe ich zuerst: Assertions oder Namen?
Testnamen zuerst. Sie geben die Spezifikation vor, gegen die die Assertions geprüft werden. Unklare Namen machen eine sinnvolle Assertion-Bewertung unmöglich.
9Wie Test-Review-Standards im Team verankern?
Checkliste als PR-Review-Vorlage, Infection als CI-Gate mit Mindest-Mutation-Score, regelmäßige Retros zu gefundenen Test-Anti-Patterns.
10Ist 100 % Coverage ein sinnvolles Ziel?
Nein – führt zu Coverage-Gaming. Sinnvoller: 80 % Coverage für Business-Logik plus 80 % Mutation Score für kritische Domänenklassen als kombiniertes Ziel.