PHPUnit Lifecycle Hooks: setUpBeforeClass im Detail
AI generated
@test
assert
PHPUnit · Lifecycle · Testisolation
PHPUnit Lifecycle Hooks im Detail
Die vollständige Reihenfolge von setUpBeforeClass bis tearDownAfterClass und was bei falscher Nutzung durchsickert

PHPUnit führt vier verschiedene Lifecycle-Hooks in einer festen Reihenfolge aus, zwei davon einmal pro Klasse, zwei davon vor und nach jedem einzelnen Test. Wer diese Reihenfolge nicht präzise kennt, baut leicht Tests, die scheinbar isoliert wirken, tatsächlich aber Zustand über static-Eigenschaften miteinander teilen. Dieser Artikel geht die komplette Kette durch, mit den typischen Fehlern und wann static- statt Instanz-Methoden nötig sind.

16 Min. Lesezeit setUpBeforeClass · setUp · tearDown Testisolation

1. Die vier Lifecycle-Phasen im Überblick

PHPUnit durchläuft für jede Testklasse eine feste Abfolge von vier Phasen: setUpBeforeClass läuft genau einmal, bevor der erste Test der Klasse startet. Dann folgt für jeden einzelnen Test das Paar aus setUp, direkt vor dem Testkörper, und tearDown, direkt danach, egal ob der Test erfolgreich war oder fehlschlug. Zum Schluss läuft tearDownAfterClass genau einmal, nachdem der letzte Test der Klasse beendet wurde.

Diese Reihenfolge ist keine Implementierungsdetail-Trivia, sondern die Grundlage für korrekte Testisolation. Jeder Test soll unabhängig von der Ausführungsreihenfolge der anderen Tests dasselbe Ergebnis liefern, und genau dafür ist setUp/tearDown gedacht: Ein garantiert frischer Zustand vor jedem einzelnen Test. Wird dieses Prinzip verletzt, entstehen Tests, die nur bestehen, wenn sie in einer bestimmten Reihenfolge laufen, ein klassisches Zeichen für versteckte Kopplung.

2. setUpBeforeClass: einmal pro Klasse, zwingend static

setUpBeforeClass muss als static-Methode deklariert werden, weil sie läuft, bevor überhaupt eine Instanz der Testklasse existiert, PHPUnit instanziiert für jeden Test eine neue Objektinstanz, aber setUpBeforeClass gehört zeitlich davor. Typische Einsatzzwecke sind teure, einmalige Vorbereitungen: eine Testdatenbank-Verbindung aufbauen, eine Fixture-Datei einmalig parsen, einen Test-Container starten.

Der entscheidende Fallstrick ist, dass alles, was in setUpBeforeClass in einer static-Eigenschaft abgelegt wird, über alle Tests der Klasse hinweg geteilt wird. Verändert ein Test diesen Zustand, sieht der nächste Test bereits die veränderte Version, nicht den ursprünglichen. Deshalb gehört in setUpBeforeClass ausschließlich wirklich unveränderlicher oder read-only genutzter Zustand, niemals etwas, das ein Test während seiner Ausführung manipuliert.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Integration;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PDO;

final class OrderRepositoryDatabaseTest extends TestCase
{
    private static PDO $connection;

    public static function setUpBeforeClass(): void
    {
        // Einmalig teure Verbindung aufbauen, fuer alle Tests dieser Klasse
        self::$connection = new PDO('sqlite::memory:');
        self::$connection->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT)');
    }

    public static function tearDownAfterClass(): void
    {
        // Verbindung wieder freigeben, Referenz entfernen
        unset(self::$connection);
    }

    #[Test]
    public function speichertBestellungInDerDatenbank(): void
    {
        self::$connection->exec("INSERT INTO orders (sku) VALUES ('ABC-123')");
        $count = self::$connection->query('SELECT COUNT(*) FROM orders')->fetchColumn();
        self::assertSame(1, (int) $count);
    }
}

3. setUp und tearDown: der eigentliche Isolationsmechanismus

setUp und tearDown laufen als Instanzmethoden vor und nach jedem einzelnen Test, weil PHPUnit für jeden Test eine frische Objektinstanz der Testklasse erzeugt. Instanzeigenschaften, die in setUp gesetzt werden, existieren also automatisch nur für die Dauer genau eines Tests, das ist der eingebaute Isolationsmechanismus, den viele Entwickler unterschätzen, weil er so selbstverständlich funktioniert.

tearDown läuft garantiert auch dann, wenn der Test mit einer Exception fehlschlägt oder eine Assertion nicht erfüllt wurde, PHPUnit fängt intern ab und ruft tearDown trotzdem auf. Das macht tearDown zum richtigen Ort für Aufräumarbeiten, die auch bei einem gescheiterten Test passieren müssen, etwa das Schließen einer temporären Datei oder das Zurücksetzen eines globalen Zustands, der außerhalb von PHPUnits eigener Kontrolle liegt.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class ShoppingCartTest extends TestCase
{
    private ShoppingCart $cart;

    protected function setUp(): void
    {
        // Neue Instanz vor JEDEM Test, garantiert frischer Zustand
        $this->cart = new ShoppingCart();
    }

    protected function tearDown(): void
    {
        // Laeuft auch bei fehlgeschlagenem Test
        $this->cart->clear();
    }

    #[Test]
    public function fuegtProduktHinzu(): void
    {
        $this->cart->add('SKU-1', 2);
        self::assertSame(2, $this->cart->quantityOf('SKU-1'));
    }

    #[Test]
    public function warenkorbIstBeiJedemTestLeer(): void
    {
        // Kein Ueberbleibsel aus dem vorherigen Test, dank setUp
        self::assertTrue($this->cart->isEmpty());
    }
}

4. Der klassische Fehler: static-Zustand sickert zwischen Tests durch

Der häufigste Lifecycle-Bug entsteht, wenn ein Entwickler eine static-Eigenschaft nutzt, um sich das wiederholte Aufsetzen in setUp zu ersparen, und dabei übersieht, dass diese Eigenschaft dann von allen Tests der Klasse geteilt wird. Läuft Test A zuerst und verändert den static-Zustand, sieht Test B bereits die veränderte Version, obwohl B davon ausgeht, mit einem unberührten Ausgangszustand zu starten.

Solche Bugs sind besonders tückisch, weil sie oft erst auffallen, wenn sich die Ausführungsreihenfolge ändert, etwa durch ein PHPUnit-Update, eine neue Testdatei im selben Verzeichnis oder die Aktivierung von zufälliger Testreihenfolge über --order-by=random. Ein Test, der isoliert grün ist, aber in der vollen Suite rot wird, ist fast immer ein Hinweis auf genau dieses Muster.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

// FALSCH: static-Zustand sickert zwischen Tests durch
final class CounterLeakyTest extends TestCase
{
    private static int $counter = 0;

    #[Test]
    public function ersterAufruf(): void
    {
        self::$counter++;
        self::assertSame(1, self::$counter);   // besteht nur, wenn dieser Test zuerst laeuft
    }

    #[Test]
    public function zweiterAufruf(): void
    {
        self::$counter++;
        self::assertSame(1, self::$counter);   // schlaegt fehl, wenn Reihenfolge sich aendert
    }
}

// RICHTIG: Zustand gehoert in eine Instanzeigenschaft, per setUp zurueckgesetzt
final class CounterIsolatedTest extends TestCase
{
    private int $counter = 0;

    protected function setUp(): void
    {
        $this->counter = 0;
    }

    #[Test]
    public function ersterAufruf(): void
    {
        $this->counter++;
        self::assertSame(1, $this->counter);
    }
}

5. Reihenfolge bei mehreren setUp-Implementierungen in Vererbungsketten

Erbt eine Testklasse von einer abstrakten Basisklasse, die selbst eine setUp-Methode definiert, muss die überschreibende setUp-Methode in der Kindklasse zwingend parent::setUp() aufrufen, PHPUnit ruft nicht automatisch beide Implementierungen auf. Vergisst man diesen Aufruf, geht die Vorbereitung der Basisklasse verloren, ohne dass PHPUnit eine Warnung ausgibt, ein besonders leicht zu übersehender Fehler bei mehrstufigen Testklassen-Hierarchien.

Die Ausführungsreihenfolge bei explizitem parent::setUp()-Aufruf hängt davon ab, ob dieser Aufruf am Anfang oder am Ende der Kindklassen-Methode steht. Bewährt hat sich, parent::setUp() konsequent als erste Zeile zu platzieren, sodass die Basisvorbereitung immer vor der spezifischen Vorbereitung der Kindklasse läuft, in Analogie zur üblichen Konstruktor-Konvention mit parent::__construct().


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Unit;

abstract class DatabaseTestCase extends \PHPUnit\Framework\TestCase
{
    protected \PDO $connection;

    protected function setUp(): void
    {
        $this->connection = new \PDO('sqlite::memory:');
    }
}

final class OrderRepositoryTest extends DatabaseTestCase
{
    private OrderRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();   // Basisvorbereitung MUSS zuerst laufen

        $this->repository = new OrderRepository($this->connection);
    }
}

6. tearDown läuft in umgekehrter Reihenfolge relativ zu setUp

Ein weniger bekanntes Detail betrifft tearDown in Vererbungsketten: Auch hier muss die Kindklasse parent::tearDown() explizit aufrufen, aber die empfohlene Konvention ist umgekehrt zu setUp, parent::tearDown() sollte am Ende der überschreibenden Methode stehen. So wird Zustand in derselben Reihenfolge aufgeräumt, in der er aufgebaut wurde, zuletzt aufgebaut heißt zuerst wieder abgebaut, ein Muster, das aus Stack-basierten Ressourcen-Verwaltungen bekannt ist.

In der Praxis ist dieses Detail vor allem relevant, wenn tearDown in mehreren Ebenen Ressourcen wie Dateihandles oder Netzwerkverbindungen schließt, die voneinander abhängen. Wird die Basisklassen-Verbindung zu früh geschlossen, während die Kindklasse noch etwas über sie aufräumen möchte, entsteht eine Exception im tearDown selbst, was PHPUnit als separaten Fehler zusätzlich zum eigentlichen Testergebnis meldet.

7. Wann static- statt Instanz-Methoden wirklich nötig sind

static gehört ausschließlich zu setUpBeforeClass und tearDownAfterClass, weil diese vor der ersten beziehungsweise nach der letzten Instanziierung laufen. Der berechtigte Einsatzzweck für static-Zustand ist eine teure, aber unveränderliche Ressource, ein hochgefahrener Test-Container, eine einmal geparste, große Fixture-Datei, eine In-Memory-Datenbankverbindung, deren Schema sich während der gesamten Testklasse nicht ändert.

Sobald ein Test diesen static-Zustand aktiv verändert, etwa Zeilen in eine Tabelle schreibt, gehört eine explizite Rücksetzung in tearDown, die den ursprünglichen Zustand wiederherstellt, zum Beispiel durch eine Transaktion, die pro Test gestartet und in tearDown zurückgerollt wird. Diese Kombination aus static-Ressource in setUpBeforeClass und Transaktions-Rollback in tearDown ist ein bewährtes Muster für performante, aber trotzdem isolierte Datenbanktests.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Integration;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PDO;

final class OrderRepositoryTransactionalTest extends TestCase
{
    private static PDO $connection;

    public static function setUpBeforeClass(): void
    {
        self::$connection = new PDO('sqlite::memory:');
        self::$connection->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT)');
    }

    protected function setUp(): void
    {
        self::$connection->beginTransaction();
    }

    protected function tearDown(): void
    {
        // Rollback stellt den Ausgangszustand fuer den naechsten Test wieder her
        self::$connection->rollBack();
    }

    #[Test]
    public function speichertBestellung(): void
    {
        self::$connection->exec("INSERT INTO orders (sku) VALUES ('X-1')");
        self::assertSame(1, (int) self::$connection->query('SELECT COUNT(*) FROM orders')->fetchColumn());
    }
}

8. assertPreConditions und assertPostConditions als ergänzende Hooks

Neben den vier Haupt-Hooks bietet PHPUnit zwei weniger bekannte Methoden, assertPreConditions, die direkt nach setUp läuft, und assertPostConditions, die direkt vor tearDown läuft. Beide sind speziell dafür gedacht, Bedingungen zu prüfen, die logisch zum Testaufbau gehören, aber getrennt vom eigentlichen Testkörper stehen sollen, etwa eine generelle Prüfung, dass die Datenbankverbindung nach dem Aufbau tatsächlich aktiv ist.

In der Praxis werden diese beiden Hooks selten benötigt, weil ihre Aufgabe meist ebenso gut in setUp beziehungsweise am Ende der Testmethode erledigt werden kann. Sinnvoll sind sie vor allem in Basisklassen, die von vielen Testklassen geerbt werden, und dort eine Invariante prüfen wollen, ohne dass jede einzelne Kindklasse diese Prüfung explizit wiederholen muss, etwa dass nach jedem Test keine offene Datenbanktransaktion zurückbleibt.

9. Fazit: Lifecycle-Disziplin als Grundlage für zuverlässige Tests

Die Lifecycle-Reihenfolge von PHPUnit ist an sich einfach, setUpBeforeClass, dann pro Test setUp und tearDown, am Ende tearDownAfterClass. Die Fehleranfälligkeit entsteht ausschließlich durch static-Zustand, der eigentlich instanzbezogen sein sollte, oder durch vergessene parent-Aufrufe in Vererbungsketten. Beides lässt sich durch eine klare Regel vermeiden: static ausschließlich für wirklich unveränderlichen oder transaktional zurückgesetzten Zustand.

Wer Tests schreibt, die zufällig in einer bestimmten Reihenfolge laufen müssen, um grün zu bleiben, hat meist genau dieses Prinzip verletzt. Ein guter Praxistest ist, die Suite regelmäßig mit --order-by=random laufen zu lassen, dabei fallen durchsickernde Abhängigkeiten fast immer sofort auf, lange bevor sie in Produktion zu einem echten Problem werden.

Hook Häufigkeit static erforderlich Typischer Einsatz
setUpBeforeClass Einmal pro Klasse, vor allen Tests Ja Teure, unveränderliche Ressource aufbauen
setUp Vor jedem einzelnen Test Nein Frischen Instanzzustand herstellen
tearDown Nach jedem einzelnen Test Nein Aufräumen, auch bei Fehlschlag
tearDownAfterClass Einmal pro Klasse, nach allen Tests Ja Ressource endgültig freigeben

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 Lifecycle Hooks: Das Wichtigste auf einen Blick

Kernidee

setUpBeforeClass läuft einmal pro Klasse, setUp und tearDown vor und nach jedem einzelnen Test.

Größter Fallstrick

static-Eigenschaften teilen Zustand über alle Tests, wenn sie während der Ausführung verändert werden.

Vererbung

parent::setUp() gehört an den Anfang, parent::tearDown() ans Ende der überschreibenden Methode.

Diagnose-Tipp

--order-by=random deckt durchsickernden Zustand fast immer sofort auf.

11. FAQ: PHPUnit Lifecycle Hooks: Das Wichtigste auf einen Blick

1Warum muss setUpBeforeClass zwingend static sein?
Weil die Methode läuft, bevor überhaupt eine Instanz der Testklasse existiert. PHPUnit erzeugt für jeden Test eine neue Objektinstanz, aber setUpBeforeClass wird davor aufgerufen, weshalb nur statische Methoden und Eigenschaften erreichbar sind.
2Läuft tearDown auch, wenn der Test eine Exception wirft?
Ja, PHPUnit fängt die Exception intern ab und ruft tearDown trotzdem auf, bevor der Fehler als Testfehlschlag gemeldet wird. Aufräumarbeiten in tearDown finden also garantiert statt.
3Was passiert, wenn ich parent::setUp() in einer Kindklasse vergesse?
Die Vorbereitung der Basisklasse wird komplett übersprungen, ohne dass PHPUnit eine Warnung ausgibt. Das führt oft zu schwer nachvollziehbaren Fehlern, etwa einer nicht initialisierten Datenbankverbindung.
4Warum sollte parent::tearDown() am Ende und nicht am Anfang stehen?
Damit Ressourcen in umgekehrter Reihenfolge zu ihrem Aufbau abgeräumt werden, zuletzt aufgebaut wird zuerst abgebaut. Das vermeidet Fehler, wenn Kindklassen-Aufräumarbeiten noch auf Basisklassen-Ressourcen angewiesen sind.
5Kann ich in setUpBeforeClass auf $this zugreifen?
Nein, da noch keine Objektinstanz existiert, ist $this in einer static-Methode nicht verfügbar. Zugriffe erfolgen ausschließlich über self:: auf static-Eigenschaften und static-Methoden.
6Wie erkenne ich durchsickernden Zustand in einer bestehenden Testsuite?
Der zuverlässigste Weg ist, die Suite mit --order-by=random laufen zu lassen. Tests, die dabei plötzlich fehlschlagen, obwohl sie einzeln oder in der Standardreihenfolge grün sind, deuten fast immer auf geteilten static-Zustand hin.
7Sollte ich eine Datenbankverbindung in setUp oder in setUpBeforeClass aufbauen?
Das hängt vom Zweck ab: Eine reine Verbindung ohne veränderten Zustand gehört in setUpBeforeClass für Performance, sobald Tests Daten schreiben, sollte pro Test eine Transaktion in setUp beginnen und in tearDown zurückgerollt werden.
8Was ist der Unterschied zwischen setUp und dem Konstruktor der Testklasse?
PHPUnit ruft für jeden Test zwar einen neuen Konstruktor auf, empfiehlt aber, Vorbereitungslogik in setUp statt im Konstruktor zu platzieren, weil setUp als expliziter Lifecycle-Hook dokumentiert und von Tools wie IDEs klar erkennbar ist.
9Muss tearDownAfterClass zwingend vorhanden sein, wenn ich setUpBeforeClass nutze?
Nicht zwingend, aber empfehlenswert, sobald in setUpBeforeClass eine Ressource geöffnet wurde, die explizit geschlossen werden sollte, etwa eine Datenbankverbindung oder ein Dateihandle, um Ressourcenlecks über mehrere Testklassen hinweg zu vermeiden.
10Gibt es Attribute als Alternative zu den Lifecycle-Methodennamen?
Ja, seit PHPUnit 10 existieren #[Before], #[After], #[BeforeClass] und #[AfterClass], mit denen beliebig benannte Methoden als Lifecycle-Hooks markiert werden können, statt sich an die festen Methodennamen zu halten.