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.
Inhaltsverzeichnis
- 1. Symptome erkennen: lokal gut, CI kaputt
- 2. Isolation als erstes Diagnose-Werkzeug
- 3. Reihenfolgeabhängigkeiten aufdecken
- 4. Globaler Zustand als häufigste Ursache
- 5. Datenbankzustand zwischen Tests sauber halten
- 6. Falsch konfigurierte Mocks und Stubs
- 7. Unterschiede zwischen lokaler Umgebung und CI
- 8. Debugging-Werkzeuge im Vergleich
- 9. Präventive Maßnahmen für stabile Test-Suiten
- 10. Zusammenfassung
- 11. FAQ
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