@test
assert
PHPUnit · Debugging · Test-Isolation · CI/CD
Fehlersuche in kaputten Test-Suites
Isolation, Reihenfolge und globaler Zustand systematisch aufdecken

Tests, die einzeln bestehen aber gemeinsam fehlschlagen, sind das frustrierendste Problem in einer PHPUnit-Suite. Dahinter stecken fast immer globaler Zustand, fehlende Isolation oder versteckte Abhängigkeiten zwischen Tests – Probleme, die mit der richtigen Methodik aufdeckbar und dauerhaft behebbar sind.

14 Min. Lesezeit --process-isolation · --order · Shared-State · CI-Debugging PHPUnit 11 · PHP 8.4

1. Symptome erkennen: lokal gut, CI kaputt

Das klassische Symptom einer kaputten Test-Suite ist die Diskrepanz zwischen lokalem Ergebnis und CI-Ergebnis. Lokal laufen alle 400 Tests grün, in der CI-Pipeline schlagen 15 fehl – und beim erneuten Ausführen der fehlgeschlagenen Tests einzeln bestehen sie wieder. Dieses Muster ist ein fast sicherer Indikator für Test-Isolation-Probleme, nicht für echte Bugs im Produktionscode. Das Verständnis dieser Unterscheidung ist der erste Schritt zur systematischen Fehlersuche.

Ein zweites Symptom: Tests schlagen fehl, aber der Fehler hat nichts mit dem getesteten Verhalten zu tun. Eine Assertion über eine Bestellsumme schlägt fehl, weil ein anderer Test zuvor die Währungskonfiguration geändert hat. Eine Datenbankabfrage gibt falsche Ergebnisse zurück, weil ein vorheriger Test Testdaten nicht zurückgesetzt hat. Der Fehler zeigt sich im Symptomtest, die Ursache liegt im auslösenden Test – oft mehrere Testklassen vorher in der Ausführungsreihenfolge.

Ein drittes Muster: Tests schlagen nur beim ersten Ausführen der Suite fehl, aber nicht beim zweiten Durchlauf. Das deutet auf temporäre Dateien, zwischengespeicherte Konfigurationen oder nicht zurückgesetzte Singleton-Zustände hin, die beim zweiten Durchlauf noch den korrekten Zustand aus dem ersten Durchlauf enthalten. Alle drei Muster haben gemeinsam, dass der Test-Output allein nicht ausreicht – die Ausführungsreihenfolge muss bekannt sein.

2. Isolation als erstes Diagnose-Werkzeug

Der erste Schritt bei der Diagnose einer kaputten Test-Suite ist die Eingrenzung durch Isolation. PHPUnit bietet mehrere Mechanismen, um Tests in verschiedenen Konfigurationen auszuführen und so die Fehlerquelle einzugrenzen. Der PHPUnit-Schalter --process-isolation führt jeden Test in einem separaten PHP-Prozess aus, was globale Variablen, statische Eigenschaften und Singleton-Zustände vollständig isoliert. Wenn alle Tests mit --process-isolation bestehen, aber ohne diesen Schalter fehlschlagen, ist die Ursache eindeutig: geteilter Zustand zwischen Tests.

Der nächste Schritt ist die Eingrenzung auf die Testklassen, die das Problem verursachen. --filter TestClassName führt einzelne Testklassen aus. Mit dem @depends-Mechanismus oder der Analyse der Setup-Methoden lässt sich oft erkennen, welche Testklasse den Zustand hinterlässt, der andere Tests beeinflusst. PHPUnit 11 bietet mit dem Attribut #[RunTestsInSeparateProcesses] eine deklarative Möglichkeit, bestimmte Testklassen immer isoliert auszuführen – ein nützliches Mittel, wenn die Quelle des geteilten Zustands in einer bestimmten Klasse bekannt ist.


<?php
// Step 1: Run single test class to confirm it passes in isolation
// vendor/bin/phpunit tests/Unit/Order/OrderServiceTest.php
// Result: PASS

// Step 2: Run full suite — does it fail?
// vendor/bin/phpunit
// Result: FAIL (OrderServiceTest::testCalculateTotal)

// Step 3: Run with process isolation — does it pass now?
// vendor/bin/phpunit --process-isolation
// Result: PASS → confirms shared state is the cause

// Step 4: Use --order=reverse to find if order matters
// vendor/bin/phpunit --order=reverse
// Result: different tests fail → order-dependent test confirmed

// Step 5: PHPUnit attribute to always isolate a specific class
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

#[RunTestsInSeparateProcesses]
final class LegacySingletonTest extends \PHPUnit\Framework\TestCase
{
    // This class modifies a global registry — must run in isolation
    public function testSomethingWithGlobalRegistry(): void
    {
        // Safe: each test in this class runs in its own process
        \GlobalRegistry::set('key', 'value');
        $this->assertSame('value', \GlobalRegistry::get('key'));
    }
}

3. Reihenfolgeabhängigkeiten aufdecken

PHPUnit führt Tests standardmäßig in der Reihenfolge aus, in der sie in der Test-Suite definiert sind – aber diese Reihenfolge ist nicht garantiert und kann sich durch Framework-Updates, neue Tests oder geänderte Dateisystemreihenfolgen ändern. Der Schalter --order=random führt Tests in zufälliger Reihenfolge aus und macht reihenfolgeabhängige Tests reproduzierbar fehlschlagen. Mit --random-order-seed=XXXX lässt sich eine bestimmte Reihenfolge reproduzieren, um das Problem zu analysieren.

Die systematische Eingrenzung der auslösenden Testklasse erfolgt durch Bisect-Verfahren: Man führt die Hälfte der Tests vor dem fehlschlagenden Test aus und prüft, ob der Fehler auftritt. Dann halbiert man diesen Teilbereich weiter, bis die auslösende Testklasse gefunden ist. Bei großen Suiten ist das mühsam, aber unumgänglich. Tools wie phpunit-randomize-order oder eigene Test-Runner-Skripte können diesen Prozess automatisieren.

4. Globaler Zustand als häufigste Ursache

In PHP-Projekten gibt es mehrere Quellen für globalen Zustand, die Test-Isolation korrumpieren. Statische Eigenschaften (static $instance in Singleton-Implementierungen), superglobale Variablen ($_SERVER, $_ENV, $_SESSION), globale Konfigurationsregister und PHP-ini-Einstellungen, die zur Laufzeit geändert werden, sind die häufigsten Kandidaten. In Magento-Projekten kommen der ObjectManager, der Config-Cache und der Event-Manager als potenzielle Quellen geteilten Zustands hinzu.

Die Lösung für Singleton-Probleme in Tests ist entweder das Zurücksetzen des Singletons in tearDown() oder das Ersetzen durch Dependency Injection, die in Tests durch Stubs ersetzt werden kann. Für superglobale Variablen bietet PHPUnit das Attribut #[BackupGlobals(true)], das alle globalen Variablen vor dem Test sichert und danach wiederherstellt. Das hat Performance-Kosten, ist aber für Legacy-Code, der nicht sofort refaktorisiert werden kann, eine pragmatische Lösung.


<?php
declare(strict_types=1);

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

// Problematic singleton that leaks state between tests
class ConfigRegistry
{
    private static array $config = [];
    private static ?self $instance = null;

    public static function getInstance(): self
    {
        return self::$instance ??= new self();
    }

    public static function reset(): void
    {
        self::$instance = null;
        self::$config   = [];
    }

    public function set(string $key, mixed $value): void
    {
        self::$config[$key] = $value;
    }

    public function get(string $key): mixed
    {
        return self::$config[$key] ?? null;
    }
}

// Correct pattern: reset static state in tearDown
final class ConfigRegistryTest extends TestCase
{
    protected function tearDown(): void
    {
        // Mandatory: reset singleton after each test
        ConfigRegistry::reset();
    }

    #[Test]
    public function storesAndRetrievesValue(): void
    {
        ConfigRegistry::getInstance()->set('currency', 'EUR');
        $this->assertSame('EUR', ConfigRegistry::getInstance()->get('currency'));
        // tearDown resets the singleton → next test starts clean
    }
}

// Alternative: PHPUnit attribute for automatic static property backup
#[BackupStaticProperties(enabled: true)]
final class AutomaticBackupTest extends TestCase
{
    #[Test]
    public function modifiesStaticPropertySafely(): void
    {
        ConfigRegistry::getInstance()->set('locale', 'de_DE');
        // PHPUnit restores all static properties after this test
    }
}

5. Datenbankzustand zwischen Tests sauber halten

Integrationstests, die eine Datenbank verwenden, erzeugen besonders häufig Reihenfolgeprobleme. Ein Test schreibt Testdaten in eine Tabelle, ein anderer Test liest aus derselben Tabelle und findet unerwartete Datensätze. Das Standardmuster ist die Verwendung von Datenbankprojektionen innerhalb einer Transaktion: Zu Beginn jedes Tests startet man eine Transaktion, am Ende wird sie zurückgerollt. So beginnt jeder Test mit einem sauberen Datenbankzustand, ohne die Tabellen zu truncaten – was deutlich schneller ist.

In Magento-Integrationstests erledigt die Annotation @magentoDbIsolation enabled genau das. In reinen PHPUnit-Projekten kann man dasselbe Muster mit einer abstrakten Basis-Testklasse umsetzen, die in setUp() eine Transaktion beginnt und in tearDown() zurückrollt. Wichtig: Diese Technik funktioniert nicht bei Tests, die selbst Transaktionen verwenden oder bei Datenbankoperationen, die außerhalb der Transaktion laufen (z.B. DDL-Statements in MySQL, die implizit committen).

6. Falsch konfigurierte Mocks und Stubs

Mocks, die nicht korrekt zurückgesetzt werden, sind eine weitere häufige Ursache für Reihenfolgeprobleme. In PHPUnit werden Mocks, die mit createMock() oder getMockBuilder() erstellt wurden, automatisch nach jedem Test zurückgesetzt – das ist das Standardverhalten. Probleme entstehen, wenn Mocks in Klassenvariablen gespeichert und in mehreren Tests wiederverwendet werden, ohne sie zurückzusetzen. Ein Mock, dessen expects($this->once())-Erwartung in einem Test ausgelöst wurde, schlägt im nächsten Test fehl, wenn er erneut aufgerufen wird.

Die Lösung ist konsequentes Erstellen frischer Mock-Objekte in setUp() statt in der Klasseninitialisierung. In Testklassen mit vielen Abhängigkeiten verleitet man sich leicht dazu, Mocks einmal zu erstellen und dann anzupassen – das führt zu versteckten Kopplungen zwischen Tests. Jede setUp()-Methode sollte den vollständigen Ausgangszustand aller Abhängigkeiten definieren, unabhängig davon, was vorherige Tests mit diesen Abhängigkeiten gemacht haben.

7. Unterschiede zwischen lokaler Umgebung und CI

Wenn Tests nur in CI fehlschlagen und lokal nie, ist der erste Schritt die Angleichung der Umgebungen. PHP-Versionsdifferenzen, verschiedene PHP-Erweiterungen (GD, intl, mbstring), unterschiedliche Systemlocale, Zeitzonen und Dateisystempfade sind häufige Kandidaten. Die Timezone ist besonders tückisch: Tests, die Datums-/Zeitberechnungen durchführen und auf einer Maschine mit Europe/Berlin entwickelt wurden, können auf einem CI-Server mit UTC fehlschlagen, ohne dass der Code logisch falsch ist.

Ein systematischer Ansatz: Docker-Container für lokale Entwicklung verwenden, die exakt dieselbe Konfiguration wie der CI-Container haben. In phpunit.xml die PHP-ini-Einstellungen explizit setzen (timezone=Europe/Berlin, memory_limit=256M), damit Tests nicht von der systemweiten Konfiguration abhängen. Environment-Variablen, die lokal in .env gesetzt sind aber in CI nicht vorhanden sind, sind ein weiterer häufiger Grund – phpunit.xml sollte alle benötigten Test-Umgebungsvariablen mit Standardwerten definieren.

8. Debugging-Werkzeuge im Vergleich

PHPUnit und das PHP-Ökosystem bieten verschiedene Werkzeuge für die Diagnose kaputtet Test-Suiten. Die richtige Wahl hängt vom Symptom ab.

Werkzeug / Schalter Symptom Was es zeigt Kosten
--process-isolation Shared-State-Verdacht Bestätigt oder widerlegt globalen Zustand Langsam (eigener Prozess je Test)
--order=random Reihenfolgeabhängigkeit Macht flüchtige Fehler reproduzierbar Keine Mehrkosten
--stop-on-failure Kaskadenfehler Zeigt den ersten echten Fehler klar Keine Mehrkosten
#[BackupStaticProperties] Statische Properties Stellt Zustand nach jedem Test wieder her Moderater Overhead
Xdebug Step Debugger Unklare Werte zur Laufzeit Zustand aller Variablen im Fehlermoment Sehr langsam, nur lokal

9. Präventive Maßnahmen für stabile Test-Suiten

Die effektivste Maßnahme gegen kaputte Test-Suiten ist präventive Isolation: Jeder Test muss seinen eigenen Zustand vollständig in setUp() aufbauen und in tearDown() bereinigen. Keine Testmethode darf auf den Zustand einer anderen Testmethode angewiesen sein, auch nicht auf die Reihenfolge des Frameworks. Das klingt selbstverständlich, wird aber in der Praxis durch schnelle Entwicklung und Zeitdruck häufig verletzt. Code-Reviews, die explizit auf fehlende tearDown()-Methoden und auf geteilte Member-Variablen zwischen Tests achten, reduzieren das Problem strukturell.

In phpunit.xml lässt sich beStrictAboutTestsThatDoNotTestAnything="true" und beStrictAboutChangesToGlobalState="true" aktivieren. Letztere Einstellung lässt Tests fehlschlagen, die globale Variablen oder statische Eigenschaften ändern, ohne sie zurückzusetzen – ein starkes, präventives Signal, das kaputte Tests früh sichtbar macht. In Kombination mit --order=random in der CI-Pipeline entsteht eine Umgebung, die Reihenfolgeabhängigkeiten zuverlässig aufdeckt, bevor sie in der Produktion Probleme verursachen.


<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml — strict configuration for stable test suites -->
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    beStrictAboutChangesToGlobalState="true"
    beStrictAboutTestsThatDoNotTestAnything="true"
    failOnRisky="true"
    failOnWarning="true"
    executionOrder="random"
    resolveDependencies="true"
>
  <php>
    <!-- Explicit timezone: never rely on system timezone -->
    <ini name="date.timezone" value="Europe/Berlin"/>
    <ini name="memory_limit" value="256M"/>
    <!-- Test environment variables with safe defaults -->
    <env name="APP_ENV" value="testing"/>
    <env name="DATABASE_URL" value="sqlite::memory:"/>
  </php>
  <testsuites>
    <testsuite name="unit">
      <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="integration">
      <directory>tests/Integration</directory>
    </testsuite>
  </testsuites>
</phpunit>

10. Zusammenfassung

Kaputte Test-Suiten entstehen fast immer durch globalen Zustand, fehlende Isolation oder Reihenfolgeabhängigkeiten – selten durch echte Bugs im Produktionscode. Der systematische Diagnoseprozess beginnt mit --process-isolation, um geteilten Zustand zu bestätigen, und --order=random, um Reihenfolgeabhängigkeiten sichtbar zu machen. Singleton-Resets in tearDown(), Datenbanktransaktionen pro Test und konsistente Mock-Erstellung in setUp() sind die drei wichtigsten präventiven Maßnahmen.

PHPUnit-Konfiguration mit strikten Einstellungen und zufälliger Ausführungsreihenfolge in der CI-Pipeline macht Isolation-Probleme frühzeitig sichtbar. Wer zusätzlich die Umgebung durch Docker-Container angleicht und alle Test-Umgebungsvariablen explizit in phpunit.xml definiert, eliminiert den Großteil der "lokal grün, CI rot"-Phänomene dauerhaft.

Kaputte Test-Suites debuggen — Das Wichtigste auf einen Blick

Erste Diagnose

--process-isolation bestätigt geteilten Zustand. --order=random deckt Reihenfolgeabhängigkeiten auf. Immer als erstes einsetzen.

Globaler Zustand

Singleton-Reset in tearDown(). #[BackupStaticProperties] für Legacy-Code. Keine statischen Member ohne Zurücksetzen.

Datenbankisolation

Transaktion in setUp() starten, in tearDown() zurückrollen. Schneller als Truncate, hinterlässt keinen Zustand für nachfolgende Tests.

CI-Parität

Timezone, PHP-Version und Erweiterungen in phpunit.xml explizit setzen. Docker-Container für lokale Entwicklung identisch zum CI-Container konfigurieren.

Mironsoft

PHPUnit-Debugging, Test-Strategie und CI/CD-Integration

Test-Suite dauerhaft stabilisieren?

Wir analysieren kaputte PHPUnit-Test-Suiten, identifizieren Isolation-Probleme und Reihenfolgeabhängigkeiten und reparieren sie dauerhaft – mit vollständiger Dokumentation der Ursachen und präventiven Maßnahmen.

Suite-Analyse

Systematische Diagnose von Isolation-Problemen und Reihenfolgeabhängigkeiten

Refactoring

setUp()/tearDown()-Struktur verbessern, Singletons und globalen Zustand beseitigen

CI-Härtung

phpunit.xml-Konfiguration, Docker-Parität und zufällige Ausführungsreihenfolge einrichten

11. FAQ: Kaputte Test-Suites debuggen

1Tests einzeln grün, gemeinsam rot – was steckt dahinter?
Geteilter Zustand oder Reihenfolgeabhängigkeit. --process-isolation und --order=random als erste Diagnose-Schritte einsetzen.
2Was macht --process-isolation?
Jeder Test läuft in einem eigenen PHP-Prozess. Globale Variablen, statische Properties und Singletons werden nicht geteilt. Wenn Tests dann bestehen: geteilter Zustand bestätigt.
3Wie den auslösenden Test finden?
Binäre Suche: erste Hälfte der Tests vor dem fehlschlagenden ausführen, weiter halbieren. --random-order-seed für reproduzierbare Reihenfolge nutzen.
4Wann #[BackupStaticProperties] verwenden?
Für Legacy-Singletons und statische Klassen, die nicht sofort refaktorisiert werden können. PHPUnit stellt nach jedem Test automatisch wieder her.
5Test lokal grün, CI rot – häufigste Ursachen?
Timezone (CI = UTC), fehlende PHP-Erweiterung, andere PHP-Version, fehlende Umgebungsvariablen. Timezone in phpunit.xml explizit setzen.
6Datenbankzustand zwischen Integrationstests?
Transaktion in setUp() starten, in tearDown() rollBack(). Schneller als Truncate, hinterlässt keinen Zustand. In Magento: @magentoDbIsolation enabled.
7Was macht beStrictAboutChangesToGlobalState?
Tests, die globale Variablen oder statische Properties ändern ohne Zurücksetzen, schlagen fehl. Starkes präventives Signal gegen Isolation-Probleme.
8Mocks in setUp() oder in den Testmethoden?
Frische Mocks immer in setUp() erstellen. Keine Mocks zwischen Tests wiederverwenden. expects()-Erwartungen aus einem Test können im nächsten zu Fehlern führen.
9Reihenfolgeabhängigkeiten präventiv verhindern?
executionOrder=random in phpunit.xml. Jeder Test baut Zustand vollständig in setUp() auf. tearDown() bereinigt alles Veränderte. Code-Reviews prüfen auf fehlende Bereinigungen.
10@depends vs. echte Test-Isolation?
@depends ist explizit und dokumentiert – kein Problem. Implizite Abhängigkeiten durch geteilten Zustand sind das Problem: unsichtbar, nicht wartbar, schwer zu debuggen.