Eigene Erweiterungen seit PHPUnit 10 schreiben
PHPUnit 10 hat den jahrelang gewachsenen TestListener durch eine sauber typisierte Extension API ersetzt. Wer eigenes Logging, Slack-Benachrichtigungen bei Fehlschlägen oder projektspezifische Regeln in die Testsuite einbauen will, findet hier den Umstieg mit lauffähigen Beispielen.
Inhaltsverzeichnis
- 1. Warum der TestListener abgelöst wurde
- 2. Wie die Extension API aufgebaut ist
- 3. TestListener und Extension API im direkten Vergleich
- 4. Einen Event-Subscriber implementieren
- 5. Beispiel: strukturiertes Logging pro Testlauf
- 6. Registrierung in der phpunit.xml
- 7. Extensions selbst testbar halten
- 8. Typische Stolperfallen beim Umstieg
- 9. Fazit für den produktiven Einsatz
- 10. Zusammenfassung
- 11. FAQ
1. Warum der TestListener abgelöst wurde
Der alte TestListener aus PHPUnit 9 und früher war ein einziges Interface mit über einem Dutzend Methoden wie startTest(), endTest() oder addError(). Wer nur auf fehlgeschlagene Tests reagieren wollte, musste trotzdem alle anderen Methoden leer implementieren. Das Interface war außerdem eng an interne PHPUnit-Klassen gekoppelt, sodass jede Major-Version eigene Breaking Changes mit sich brachte und Erweiterungen ständig nachgepflegt werden mussten.
Mit PHPUnit 10 kam ein kompletter Neuanfang: die Extension API. Statt eines monolithischen Interfaces gibt es jetzt feingranulare Events, auf die man sich gezielt abonniert, sowie ein eigenes Extension-Interface für die Registrierung. Der Vorteil liegt nicht nur in besserer Typsicherheit, sondern auch darin, dass Erweiterungen nur auf die Events reagieren, die sie wirklich interessieren, und dadurch robuster gegenüber internen PHPUnit-Änderungen bleiben.
2. Wie die Extension API aufgebaut ist
Im Zentrum steht das Interface PHPUnit\Runner\Extension\Extension mit genau einer Methode: bootstrap(). Diese Methode bekommt die Konfiguration, einen Facade-Zugriff auf die Registrierung und ein Parameter-Objekt übergeben. Innerhalb von bootstrap() registriert man sich für konkrete Events wie Test\Finished, Test\Failed oder TestSuite\Started, indem man einen Subscriber übergibt, der wiederum ein Event-spezifisches Interface implementiert.
Diese Trennung zwischen Extension-Bootstrap und Event-Subscriber sorgt dafür, dass jede Erweiterung nur die Events abonniert, die sie tatsächlich braucht. Ein Slack-Notifier interessiert sich zum Beispiel nur für fehlgeschlagene und für abgeschlossene Testläufe, nicht aber für jeden einzelnen erfolgreichen Test. Das reduziert nicht nur Boilerplate-Code, sondern macht auch die Laufzeit-Performance vorhersehbarer, weil PHPUnit nur die tatsächlich abonnierten Events überhaupt dispatcht.
<?php
declare(strict_types=1);
namespace App\Testing\Extension;
use PHPUnit\Runner\Extension\Extension;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\TextUI\Configuration\Configuration;
/**
* Grundgeruest einer eigenen PHPUnit-Extension.
*/
final class SlackNotifierExtension implements Extension
{
public function bootstrap(
Configuration $configuration,
Facade $facade,
ParameterCollection $parameters
): void {
$webhookUrl = $parameters->has('webhookUrl')
? $parameters->get('webhookUrl')
: getenv('SLACK_WEBHOOK_URL') ?: '';
$facade->registerSubscribers(
new TestFailedSubscriber($webhookUrl),
new TestSuiteFinishedSubscriber($webhookUrl),
);
}
}
3. TestListener und Extension API im direkten Vergleich
Wer eine bestehende Erweiterung migriert, stellt schnell fest, dass sich nicht jede alte Listener-Methode eins zu eins auf ein Event abbilden lässt. startTestSuite() und endTestSuite() entsprechen recht direkt TestSuite\Started und TestSuite\Finished, während die feingranularen Fehlerarten wie addError(), addFailure() und addWarning() jetzt jeweils eigene Events wie Test\Errored, Test\Failed und Test\WarningTriggered bekommen haben.
Der größte praktische Unterschied liegt in der Registrierung: Ein TestListener wurde als PHP-Objekt in der phpunit.xml mit vollständig instanziierten Konstruktor-Argumenten eingebunden, was bei komplexeren Abhängigkeiten schnell unhandlich wurde. Eine Extension bekommt ihre Konfiguration dagegen über einfache Parameter aus der XML-Datei und baut ihre Abhängigkeiten selbst in bootstrap() auf, was die Konfiguration deutlich lesbarer macht und Testbarkeit der Extension selbst erleichtert.
4. Einen Event-Subscriber implementieren
Jeder Event-Subscriber implementiert ein Interface, das exakt zu einem Event gehört, etwa PHPUnit\Event\Test\FailedSubscriber mit der Methode notify(Failed $event). Das Event-Objekt liefert alles, was man für eine sinnvolle Benachrichtigung braucht: den vollen Testnamen, die Fehlermeldung und einen Zeitstempel. Weil das Event-System typisiert ist, gibt es keine Rätselraten mehr darüber, welche Daten in welcher Methode zur Verfügung stehen.
Für den Slack-Notifier reicht es, im Konstruktor die Webhook-URL entgegenzunehmen und in notify() eine HTTP-Anfrage abzusetzen. Wichtig ist dabei, den Subscriber möglichst schlank zu halten und die eigentliche Versandlogik in eine separate, isoliert testbare Klasse auszulagern, damit man diese Klasse ganz normal mit einem eigenen PHPUnit-Test gegen einen HTTP-Client-Mock absichern kann, statt sich auf den echten PHPUnit-Lauf verlassen zu müssen.
<?php
declare(strict_types=1);
namespace App\Testing\Extension;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* Reagiert auf fehlgeschlagene Tests und stoesst eine Slack-Nachricht an.
*/
final class TestFailedSubscriber implements FailedSubscriber
{
public function __construct(private readonly string $webhookUrl)
{
}
public function notify(Failed $event): void
{
if ($this->webhookUrl === '') {
return;
}
$message = sprintf(
'Test fehlgeschlagen: %s%s%s',
$event->test()->name(),
PHP_EOL,
$event->throwable()->message(),
);
$this->send($message);
}
private function send(string $message): void
{
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode(['text' => $message], JSON_THROW_ON_ERROR),
],
]);
@file_get_contents($this->webhookUrl, false, $context);
}
}
5. Beispiel: strukturiertes Logging pro Testlauf
Ein häufiger Anwendungsfall neben Benachrichtigungen ist eigenes strukturiertes Logging, etwa um Laufzeiten pro Testklasse in eine JSON-Datei zu schreiben, die anschließend in ein Monitoring-Dashboard eingespeist wird. Dafür abonniert man Test\Started, um den Startzeitpunkt zu merken, und Test\Finished, um die Differenz zu berechnen und das Ergebnis zu persistieren.
Weil Extensions in bootstrap() beliebige Abhängigkeiten aufbauen dürfen, lässt sich hier ganz normal ein PSR-3-kompatibler Logger injizieren, der die gesammelten Laufzeiten am Ende in eine Datei oder an einen externen Dienst schreibt. Das ist deutlich flexibler als das früher verbreitete Muster, Timing-Informationen über globale Variablen oder statische Klassenattribute zwischen den Listener-Methoden zu transportieren.
<?php
declare(strict_types=1);
namespace App\Testing\Extension;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
use PHPUnit\Event\Test\Started;
use PHPUnit\Event\Test\StartedSubscriber;
/**
* Misst die Laufzeit jedes Tests und schreibt sie strukturiert weg.
*/
final class TimingStartedSubscriber implements StartedSubscriber
{
public function __construct(private readonly TimingCollector $collector)
{
}
public function notify(Started $event): void
{
$this->collector->start($event->test()->id());
}
}
final class TimingFinishedSubscriber implements FinishedSubscriber
{
public function __construct(private readonly TimingCollector $collector)
{
}
public function notify(Finished $event): void
{
$this->collector->finish($event->test()->id());
}
}
6. Registrierung in der phpunit.xml
Damit PHPUnit eine Extension überhaupt lädt, muss sie im <extensions>-Block der phpunit.xml eingetragen werden. Anders als beim alten TestListener übergibt man dort keine Konstruktor-Argumente mehr direkt, sondern einfache Schlüssel-Wert-Parameter, die anschließend über das ParameterCollection-Objekt in bootstrap() ausgelesen werden.
Diese Trennung von Konfiguration und Objekterzeugung hat einen praktischen Nebeneffekt: Sensible Werte wie Webhook-URLs oder API-Tokens müssen nicht mehr im Klartext in der versionierten phpunit.xml stehen, sondern können über Umgebungsvariablen eingeschleust werden, während die XML-Datei nur den Namen der zu lesenden Variable referenziert.
<?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">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<extensions>
<bootstrap class="App\Testing\Extension\SlackNotifierExtension">
<parameter name="webhookUrl" value="${SLACK_WEBHOOK_URL}"/>
</bootstrap>
<bootstrap class="App\Testing\Extension\TimingLoggerExtension">
<parameter name="logFile" value="var/log/phpunit-timings.json"/>
</bootstrap>
</extensions>
</phpunit>
7. Extensions selbst testbar halten
Eine Extension, die nur PHPUnit-interne Events konsumiert, lässt sich schwer im laufenden PHPUnit-Prozess selbst testen, ohne rekursive Abhängigkeiten zu erzeugen. Der bewährte Ansatz ist deshalb, die eigentliche fachliche Logik, etwa das Formatieren einer Slack-Nachricht oder das Zusammenbauen eines JSON-Log-Eintrags, in eine reine, von PHPUnit-Klassen unabhängige Klasse auszulagern.
Der Event-Subscriber selbst bleibt dann bewusst dünn: Er übernimmt nur die Übergabe von Event-Daten an diese fachliche Klasse. So kann man die Formatierungs- und Versandlogik in einem ganz normalen Unit-Test gegen einen Mock des HTTP-Clients prüfen, ohne einen kompletten PHPUnit-Testlauf innerhalb eines Testlaufs simulieren zu müssen, was ohnehin technisch problematisch wäre. Dieses Prinzip, fachliche Logik von PHPUnit-spezifischer Infrastruktur zu trennen, zahlt sich zusätzlich aus, sobald eine Extension später von einem anderen Projekt wiederverwendet werden soll, weil die fachliche Klasse dann ohne Anpassung übernommen werden kann.
8. Typische Stolperfallen beim Umstieg
Ein häufiger Fehler ist, den Namespace-Unterschied zwischen den alten PHPUnit-9-Klassen und den neuen Event-Interfaces zu übersehen. Wer versehentlich noch PHPUnit\Framework\TestListener implementiert, bekommt in PHPUnit 10 und neuer weder eine Fehlermeldung noch eine Warnung, die Extension wird schlicht nie aufgerufen, weil das alte Interface komplett entfernt wurde und PHPUnit gar nicht mehr danach sucht.
Ein zweiter, subtilerer Fehler ist das Abonnieren zu vieler Events aus reiner Vorsicht. Wer beispielsweise sowohl Test\Finished als auch Test\Passed abonniert, obwohl nur fehlgeschlagene Tests interessieren, erzeugt unnötigen Overhead bei großen Testsuiten mit tausenden Tests. Es lohnt sich, vor der Implementierung genau zu prüfen, welches der über hundert verfügbaren Event-Typen wirklich die benötigte Information liefert.
9. Fazit für den produktiven Einsatz
Die Extension API ist kein reiner Ersatz für den TestListener, sondern eine deutlich robustere Grundlage für projektspezifische Testinfrastruktur. Für Magento- und größere PHP-Projekte lohnt sich der Aufwand besonders dann, wenn CI-Pipelines auf schnelle Rückmeldung angewiesen sind und ein Fehlschlag im Hauptzweig sofort im Team-Chat sichtbar werden soll, statt erst beim nächsten Blick auf die CI-Oberfläche aufzufallen.
Wer heute noch mit einem alten TestListener aus PHPUnit 9 arbeitet, sollte den Umstieg nicht auf die lange Bank schieben: Ab PHPUnit 10 gibt es keinen Kompatibilitätsmodus mehr, und je länger man wartet, desto mehr Legacy-Listener-Code sammelt sich an, der beim eigentlichen Versions-Upgrade dann in einem Rutsch migriert werden muss.
| Aspekt | TestListener (PHPUnit 9) | Extension API (PHPUnit 10+) | Praxisrelevanz |
|---|---|---|---|
| Registrierung | Vollständiges PHP-Objekt in phpunit.xml | Klassenname plus einfache Parameter | Weniger Kopplung an Konstruktor-Signaturen |
| Granularität | Ein Interface mit allen Methoden | Ein Interface pro Event-Typ | Nur relevante Events abonnieren |
| Typsicherheit | Generische Parameter wie TestCase | Typisierte Event-Objekte | Bessere IDE-Unterstützung und weniger Laufzeitfehler |
| Konfigurationsdaten | Direkt im Konstruktor der XML | Über ParameterCollection | Sensible Werte per Umgebungsvariable einschleusen |
| Kompatibilität | Entfernt ab PHPUnit 10 | Aktueller Standard | Migration ist nicht optional |
Mironsoft
Testautomatisierung, Magento-Qualitätssicherung und CI-Integration
Tests, die echte Fehler finden statt nur grün zu leuchten?
Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.
Test-Audit
Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.
Teststrategie
Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.
CI-Integration
Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.
10. Zusammenfassung
PHPUnit Extension API: Das Wichtigste auf einen Blick
Ablösung
Die Extension API ersetzt den TestListener vollständig ab PHPUnit 10, es gibt keinen Kompatibilitätsmodus.
Architektur
Ein Extension-Bootstrap registriert typisierte Event-Subscriber statt eines monolithischen Interfaces.
Konfiguration
Parameter kommen aus der phpunit.xml, sensible Werte lassen sich über Umgebungsvariablen einschleusen.
Testbarkeit
Fachliche Logik gehört in separate Klassen, der Event-Subscriber bleibt bewusst dünn.