Ursprünglich habe ich diesen Artikel am 7. August 2026 als Newsletter an meine Abonnentinnen und Abonnenten verschickt. Weitere Informationen findest du am Ende dieses Artikels.
PHPUnit 13.3 hat zwei Schwerpunkte. Der erste ist der Umgang mit unzuverlässigen Tests: Der Test Runner kann jeden Test jetzt wiederholt ausführen, um Flaky Tests aufzuspüren, und fehlgeschlagene Tests erneut versuchen, wo sich Flakiness nicht beseitigen lässt. Der zweite ist der HTML-Report für Code Coverage: eine neue Sicht auf Namespaces und Klassen, eine deutlich bessere Darstellung von Branch Coverage und Path Coverage und ein Filter nach Testgröße.
Flaky Tests erkennen
Ein Flaky Test schlägt manchmal fehl und manchmal nicht, ohne dass sich der getestete Code geändert hätte. Typische Ursachen sind geteilter veränderlicher Zustand, zeitabhängige Logik, nichtdeterministische Reihenfolgen und nicht freigegebene Ressourcen. Der zuverlässigste Weg, solche Tests aufzuspüren, ist denkbar einfach: denselben Test viele Male hintereinander ausführen.
Bis PHPUnit 9 gab es eine Option namens --repeat. Sie wiederholte allerdings die gesamte Testsuite und war damit eher ein Benchmarking-Werkzeug. In PHPUnit 10 habe ich sie deshalb entfernt. Der Wunsch nach ihrer Rückkehr (#5718) wurde zu einem der am stärksten diskutierten Issues im Issue Tracker von PHPUnit. Beim Code Sprint in München im Oktober 2024 entstand ein neuer Konsens: --repeat sollte zurückkommen, aber nicht die Suite wiederholen, sondern jeden einzelnen Test.
Genau das tut PHPUnit 13.3 (#6591). Mit --repeat 100 läuft jeder Test bis zu hundertmal direkt hintereinander. Schlägt eine Wiederholung fehl, werden die verbleibenden Wiederholungen dieses Tests übersprungen. Jede Wiederholung läuft als eigenständiger Test und weist sich in der Ausgabe als (repetition N of M) aus. Das hilft nicht nur gegen Flakiness: Auch Code, der Verbindungen, Caches oder geöffnete Dateien verwaltet, verhält sich beim ersten Aufruf vielleicht korrekt und leckt erst über viele Aufrufe hinweg Zustand.
Mit dem Attribut #[Repeat] markierst du einzelne Testmethoden, das Vorbild dafür war die Annotation @RepeatedTest aus JUnit 5. #[Repeat(100)] wiederholt einen Test bis zu hundertmal und stoppt beim ersten Fehlschlag. Der optionale zweite Parameter steuert, wie viele Fehlschläge sich ansammeln dürfen, bevor die restlichen Wiederholungen übersprungen werden. #[Repeat(100, 5)] wiederholt so lange, bis fünf Fehlschläge beobachtet wurden. Das ist nützlich, wenn das Fehlermuster selbst die gesuchte Information ist: wie oft und bei welchen Wiederholungen ein Test fehlschlägt. Wichtig dabei: Der Schwellenwert macht Fehlschläge nicht akzeptabel. Jede fehlgeschlagene Wiederholung wird als Fehlschlag gemeldet und lässt den Testlauf fehlschlagen.
Nicht jeder Test kann wiederholt werden. Der Rückgabewert einer Testmethode kann über #[Depends] an andere Tests weitergegeben werden und eine Wiederholung würde mehrere, potenziell unterschiedliche Rückgabewerte erzeugen. Deshalb werden nur Testmethoden wiederholt, die explizit void als Rückgabetyp deklarieren und nicht von anderen Tests abhängen. Bei --repeat laufen alle anderen Tests unverändert genau einmal. Steht das Attribut #[Repeat] dagegen an einer Methode, die diese Bedingungen nicht erfüllt, gibt der Test Runner eine Warnung aus. Verwendet ein Test einen Data Provider, wird jeder Datensatz unabhängig von den anderen wiederholt.
Flaky Tests tolerieren
Nicht jeder Flaky Test lässt sich beheben. Manche Tests interagieren mit Netzwerkdiensten, Hardware oder Prozessen, deren Timing sie nicht kontrollieren können. Dass so ein Test einmal fehlschlägt, ist keine Information. Dass er wiederholt fehlschlägt, schon. Bisher blieb oft nur, den gesamten CI-Job neu zu starten. Das ist teuer und verschleiert, welcher Test das Problem war.
PHPUnit 13.3 bietet eine bessere Alternative (#6742). Ein Test mit dem Attribut #[Retry(3)] wird bis zu dreimal ausgeführt. Der erste Versuch, der weder Failure noch Error ist, entscheidet über das Ergebnis. Ein übersprungener oder unvollständiger Versuch beendet die Schleife also ebenfalls. Jeder Versuch läuft auf einer frischen Instanz der Testklasse, wie es PHPUnits Garantie „eine Instanz pro Test“ entspricht. Mit der Kommandozeilenoption --retry gilt dieses Verhalten für alle geeigneten Tests und die Bedingungen sind dieselben wie bei der Wiederholung. Ein Attribut am Test hat stets Vorrang vor der Kommandozeilenoption. --repeat und --retry gemeinsam zu verwenden ergibt eine Warnung, ebenso #[Repeat] und #[Retry] an derselben Methode.
Der übliche Einwand gegen eingebaute Retries lautet: Sie institutionalisieren Flaky Tests. Eine Race Condition, die in dreißig Prozent der Fälle zuschlägt, besteht die CI für immer und wird nie behoben. Die Implementierung in PHPUnit 13.3 begegnet diesem Einwand mit bedingungsloser Sichtbarkeit. Jeder Test, der erst nach einem oder mehreren Fehlversuchen bestanden hat, wird in der Zusammenfassung des Testlaufs aufgeführt, zusammen mit der Zahl seiner Fehlversuche. Das lässt sich nicht abschalten. Ein Test, der alle Versuche ausschöpft, schlägt ganz normal fehl; die Fehlermeldung des letzten Versuchs weist sich dabei als (attempt 3 of 3) aus. Ein Test, der beim ersten Versuch besteht, was der Normalfall sein sollte, erzeugt dagegen nirgendwo Retry-bezogene Ausgaben.
Code Coverage aus Sicht deiner Klassen
Der HTML-Report für Code Coverage zeigte deinen Code bisher so, wie er auf der Festplatte liegt: als Verzeichnisse und Dateien. Über Code denkst du aber selten in Dateien nach. Du denkst in Namespaces, Klassen und Methoden.
PHPUnit 13.3 ergänzt deshalb eine klassenorientierte Sicht (#1140). Jede Seite des Reports zeigt jetzt zwei Tabs: Files und Classes. Der Tab Files zeigt den Report wie bisher. Der Tab Classes organisiert dieselben Daten nach Namespaces und Klassen. Beide Sichten sind miteinander verknüpft: Von der Seite einer Quellcodedatei führt der Tab Classes direkt zur Seite der Klasse, die in ihr deklariert ist, und umgekehrt.
Das Herzstück sind die Klassenseiten. Jede Klasse erhält eine eigene Seite mit einer Übersichtstabelle ihrer Metriken, einschließlich des CRAP-Index je Methode, und ihrem annotierten Quellcode. Gezeigt werden nur die Zeilen, die zur Klasse gehören, nicht die ganze Datei. Dazu kommen eigene Abschnitte für jeden Trait, den die Klasse verwendet, und für jede Methode, die sie von Elternklassen erbt; überschriebene Methoden werden nicht wiederholt. Eine Klassenseite beantwortet damit die Frage „Wie gut ist diese Klasse getestet?“ für den Code, aus dem die Klasse zur Laufzeit tatsächlich besteht, ohne dass du zwischen mehreren Dateiseiten springen musst.
Namespace-Seiten aggregieren die Metriken ihrer Klassen und Unter-Namespaces, so wie es die Verzeichnisseiten der dateiorientierten Sicht tun. Teilt sich der gesamte Code einen gemeinsamen Wurzel-Namespace, werden redundante Zwischenebenen zusammengefasst. Und wie die dateiorientierte Sicht hat auch die klassenorientierte Sicht ein Dashboard mit Bubble Charts für Coverage und Komplexität.
Zwei Dinge solltest du wissen. Die beiden Sichten aggregieren bewusst unterschiedlich: In der klassenorientierten Sicht zählt Code aus Traits und Elternklassen zur jeweiligen Klasse. Die Summen können also von denen der dateiorientierten Sicht abweichen, und Funktionen sowie Code außerhalb von Klassen sind nur in der dateiorientierten Sicht zu sehen. Außerdem wird die klassenorientierte Sicht standardmäßig erzeugt und verdoppelt die Größe des Reports ungefähr. Mit den neuen Kommandozeilenoptionen --without-class-view und --without-file-view beziehungsweise den Attributen classView und fileView in der XML-Konfigurationsdatei kannst du jede der beiden Sichten abschalten.
Branch und Path Coverage sichtbar gemacht
Die Sicht auf die Branch Coverage macht nun Entscheidungspunkte im Code sichtbar (#1141). Eine neue Randspalte zeigt auf jeder Zeile, auf der sich der Kontrollfluss verzweigt, einen Punkt pro möglichem Ausgang: in der Farbe für abgedeckten Code, wenn ein Test diesen Ausgang ausgeführt hat, sonst in der Farbe für nicht abgedeckten Code. So erkennst du auf einen Blick ein if, dessen else-Fall nie ausgeführt wird, auch wenn die Zeile selbst als abgedeckt erscheint. Der Abschnitt unter dem Quellcode wurde neu gestaltet. Statt den Quellcode jedes Branches zu wiederholen, gibt es jetzt eine kompakte Tabelle je Methode mit einer Zeile pro Branch, dazu ein Badge wie 3/4 an jeder Methodenüberschrift.
Die Sicht auf die Path Coverage wurde genauso überarbeitet und erhält zusätzlich einen Kontrollflussgraphen je Methode. Der Graph zeigt die Branches der Methode als Knoten, verbunden durch Pfeile vom Eintritt bis zum Austritt, eingefärbt nach Abdeckung. Klickst du auf eine Zeile in der Pfad-Tabelle, wird der zugehörige Pfad im Graphen hervorgehoben. So siehst du genau, welchen Weg durch die Methode ein Pfad beschreibt. Für das Rendern der Graphen wird das Werkzeug dot aus Graphviz benötigt; steht es nicht zur Verfügung, wird der Report ohne Graphen erzeugt. Methoden mit mehr als 100 Pfaden wurden bisher gar nicht aufgeführt. Jetzt werden ihre ersten 100 Pfade in einem eingeklappten Abschnitt gezeigt, zusammen mit einem Hinweis auf die Gesamtzahl.
Code Coverage nach Testgröße filtern
Mit den Attributen #[Small], #[Medium] und #[Large] deklarierst du die Größe deiner Tests. PHPUnit zeichnet diese Information seit jeher in den Code-Coverage-Daten auf, der HTML-Report nutzte sie bisher aber nur für die Einfärbung abgedeckter Zeilen. Jetzt kannst du den gesamten Report danach filtern (#1153).
Jede Verzeichnis- und Dateiseite zeigt eine Schaltflächengruppe mit Small, Medium, Large und All. Wählst du eine oder mehrere Größen aus, werden alle Zahlen auf der Seite neu berechnet: Balken, Prozentwerte und die Zähler für Zeilen, Methoden und Klassen. Eine Klasse kann insgesamt einhundert Prozent Coverage zeigen, obwohl nur ein Bruchteil davon von kleinen Tests stammt und der Rest erst durch große End-to-End-Tests erreicht wird. Ein Klick auf Small zeigt dir, was deine Unit Tests allein abdecken. Unter einem aktiven Filter gilt eine Methode nur dann als getestet, wenn Tests der gewählten Größen sie allein vollständig abdecken, und eine Klasse nur dann, wenn das für alle ihre Methoden gilt.
All und die Kombination aus Small, Medium und Large sind bewusst nicht dasselbe. All ignoriert Testgrößen vollständig und zählt jede abgedeckte Zeile. Die Kombination der drei Größen zählt nur Zeilen, die von mindestens einem Test mit deklarierter Größe abgedeckt werden. Die Differenz zwischen beiden zeigt dir, wie viel deiner Coverage von Tests stammt, die keine Größe deklarieren.
Livestream
Hier ist die Aufzeichnung des Livestreams, in dem ich die wichtigsten Änderungen in PHPUnit 13.3 erläutert habe:
Exklusive Einblicke direkt in deinem Posteingang
Alle zwei Monate, zeitgleich mit jedem PHPUnit Feature Release, schicke ich meinen Abonnentinnen und Abonnenten eine ausführliche Einordnung der neuen Funktionen: was sie tun, wie sie implementiert sind und warum sie aufgenommen wurden. Das ist der Kontext, den das ChangeLog nicht liefert.
Den Inhalt des Newsletters veröffentliche ich einen Monat nach dem Versand an meine Abonnenten hier auf dieser Website. Jetzt abonnieren, um die Informationen so früh wie möglich zu erhalten.