PHPUnit Extension API: Eigene Erweiterungen seit PHPUnit 10 schreiben
AI generated
@test
assert
PHPUnit · Extension API · PHP
PHPUnit Extension API
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.

15 Min. Lesezeit Extension API TestListener-Ablösung PHPUnit 10+

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.

11. FAQ: PHPUnit Extension API: Das Wichtigste auf einen Blick

1Ab welcher PHPUnit-Version ist der TestListener komplett entfernt?
Der TestListener wurde mit PHPUnit 10 vollständig entfernt. Es gibt keinen Kompatibilitätsmodus, Projekte müssen für den Umstieg auf PHPUnit 10 oder neuer zwingend auf die Extension API wechseln.
2Was ist der wichtigste strukturelle Unterschied zur alten Lösung?
Statt eines einzigen Interfaces mit vielen Pflichtmethoden abonniert man jetzt gezielt einzelne, typisierte Events über separate Subscriber-Klassen, was Boilerplate reduziert und die IDE-Unterstützung verbessert.
3Wie übergibt man Konfigurationswerte an eine eigene Extension?
Über den -Tag im -Block der phpunit.xml. In bootstrap() liest man die Werte über das ParameterCollection-Objekt aus, statt sie im Konstruktor der XML-Datei zu übergeben.
4Kann eine Extension mehrere Events gleichzeitig abonnieren?
Ja, über facade->registerSubscribers() lassen sich beliebig viele Subscriber-Instanzen gleichzeitig registrieren, jede für ein anderes Event, alle innerhalb derselben bootstrap()-Methode.
5Wie testet man eine eigene Extension sinnvoll?
Die fachliche Logik, etwa Nachrichtenformatierung oder Log-Serialisierung, gehört in eine eigenständige Klasse ohne PHPUnit-Abhängigkeiten. Diese Klasse lässt sich dann mit einem ganz normalen Unit-Test absichern.
6Welche Events gibt es für fehlgeschlagene Tests?
Unter anderem Test\Failed für Assertion-Fehlschläge, Test\Errored für unerwartete Exceptions und Test\WarningTriggered für Warnungen. Jeder Fehlerfall hat ein eigenes, typisiertes Event.
7Lohnt sich eine eigene Extension für kleine Projekte?
Für sehr kleine Projekte reicht oft ein einfaches CI-Skript, das den Exit-Code von PHPUnit auswertet. Eigene Extensions lohnen sich vor allem, wenn projektspezifische Logik direkt an Testereignisse gekoppelt werden soll.
8Kann man mehrere Extensions gleichzeitig registrieren?
Ja, im -Block der phpunit.xml lassen sich beliebig viele bootstrap-Einträge mit unterschiedlichen Extension-Klassen und jeweils eigenen Parametern hinterlegen.
9Was passiert, wenn eine alte TestListener-Klasse noch in der phpunit.xml steht?
Ab PHPUnit 10 wird die Konfiguration in der Regel als Fehler abgelehnt oder die Klasse schlicht ignoriert, da das TestListener-Interface nicht mehr existiert. Ein Blick in die PHPUnit-Migrationshinweise ist vor dem Upgrade Pflicht.
10Gibt es fertige Extensions, die man einbinden kann, statt selbst zu schreiben?
Ja, im Composer-Ökosystem existieren bereits Extensions für Coverage-Reporting, Testreihenfolge-Randomisierung und CI-Integrationen. Für sehr spezifische Anforderungen wie interne Chat-Benachrichtigungen bleibt eine eigene Extension aber oft die pragmatischste Lösung.