@runInSeparateProcess und @preserveGlobalState: Wann sie wirklich noetig sind und was sie kosten
Manche Tests lassen sich nur sauber schreiben, wenn PHPUnit sie in einem komplett neuen PHP-Prozess ausfuehrt: etwa wenn Legacy-Code Konstanten definiert, die nicht neu gesetzt werden koennen, oder wenn statische Zustaende so tief verwoben sind, dass ein Reset in tearDown nicht ausreicht. @runInSeparateProcess loest dieses Problem zuverlaessig, aber nicht kostenlos: Jeder isolierte Test startet einen eigenen PHP-Interpreter, was die Laufzeit einer Suite spuerbar in die Hoehe treiben kann, wenn man es zu grosszuegig einsetzt.
Inhaltsverzeichnis
- 1. Warum PHP-Tests ueberhaupt einen eigenen Prozess brauchen koennen
- 2. @runInSeparateProcess in der Praxis
- 3. @preserveGlobalState und warum es meist deaktiviert werden muss
- 4. Was ein isolierter Test tatsaechlich kostet
- 5. Alternativen, bevor man zu Prozessisolation greift
- 6. Wechselwirkung mit paralleler Testausfuehrung
- 7. Faelle, in denen Prozessisolation wirklich unvermeidbar ist
- 8. Strategien fuer Suiten mit vielen isolierten Tests
- 9. Eine praktische Entscheidungshilfe
- 10. Zusammenfassung
- 11. FAQ
1. Warum PHP-Tests ueberhaupt einen eigenen Prozess brauchen koennen
PHPUnit fuehrt standardmaessig alle Tests einer Suite im selben PHP-Prozess aus. Das ist schnell, weil kein neuer Interpreter gestartet werden muss, bringt aber eine wichtige Konsequenz mit sich: Alles, was in PHP wirklich global ist, also einmal definierte Konstanten, geladene Klassen oder bestimmte Erweiterungszustaende, bleibt fuer die gesamte Dauer des Testlaufs bestehen. Anders als bei statischen Properties, die man in tearDown zuruecksetzen kann, gibt es fuer manche PHP-Konstrukte schlicht keinen Weg, sie innerhalb desselben Prozesses wieder in den Ausgangszustand zu bringen.
Das klassische Beispiel ist eine mit define() gesetzte Konstante. PHP erlaubt es nicht, eine bereits definierte Konstante neu zu setzen oder zu loeschen. Wenn zwei Tests dieselbe Konstante mit unterschiedlichen Werten benoetigen, etwa um zwei verschiedene Umgebungen zu simulieren, gibt es innerhalb eines einzigen Prozesses keine Loesung. Genau fuer solche Faelle bietet PHPUnit die Annotation @runInSeparateProcess an, die den betroffenen Test in einem komplett neuen, frisch gestarteten PHP-Prozess ausfuehrt.
2. @runInSeparateProcess in der Praxis
Mit der Annotation @runInSeparateProcess (beziehungsweise dem entsprechenden Attribut RunInSeparateProcess in neueren PHPUnit-Versionen) markiert man eine einzelne Testmethode oder eine ganze Testklasse. PHPUnit serialisiert dann den Testkontext, startet einen neuen PHP-Prozess, deserialisiert den Kontext dort wieder und fuehrt den Test aus. Alles, was in diesem Prozess an Konstanten, globalem Zustand oder geladenen Extensions passiert, bleibt vollstaendig vom Rest der Suite isoliert.
Das macht es moeglich, Tests fuer Legacy-Code zu schreiben, der etwa eine Konstante wie APPLICATION_ENV mit unterschiedlichen Werten pruefen muss, ohne dass ein Test den naechsten beeinflusst. Wichtig ist, dass die Isolation nur fuer den markierten Test gilt: Andere Tests in derselben Klasse laufen weiterhin im regulaeren, gemeinsamen Prozess, es sei denn, die Annotation steht auf Klassenebene und gilt damit fuer alle Methoden darin.
final class LegacyEnvironmentConfigTest extends \PHPUnit\Framework\TestCase
{
/**
* @runInSeparateProcess
*/
public function testProductionEnvironmentDisablesDebugOutput(): void
{
define('APPLICATION_ENV', 'production');
$config = new LegacyEnvironmentConfig();
self::assertFalse($config->isDebugEnabled());
}
/**
* @runInSeparateProcess
*/
public function testDevelopmentEnvironmentEnablesDebugOutput(): void
{
define('APPLICATION_ENV', 'development');
$config = new LegacyEnvironmentConfig();
self::assertTrue($config->isDebugEnabled());
}
}
3. @preserveGlobalState und warum es meist deaktiviert werden muss
Standardmaessig versucht PHPUnit, beim Start des isolierten Prozesses moeglichst viel vom aktuellen globalen Zustand, etwa Autoloader-Registrierungen und bestimmte Superglobale, in den neuen Prozess zu uebertragen. Das ist praktisch, wenn der Test auf Klassen aus dem Hauptprojekt zugreifen muss, kann aber in bestimmten Konstellationen selbst wieder Probleme verursachen, etwa wenn Objekte im globalen Zustand nicht serialisierbar sind oder Ressourcen wie offene Dateihandles enthalten.
Die Annotation @preserveGlobalState steuert dieses Verhalten explizit. Mit dem Wert disabled wird kein Zustand uebertragen, und der neue Prozess startet komplett unbeladen, muss sich aber selbst um alles kuemmern, was er braucht, etwa ueber den regulaeren Bootstrap von phpunit.xml. In der Praxis ist disabled oft die robustere Wahl, weil die automatische Zustandsuebertragung bei komplexeren Objektgraphen leicht an Serialisierungsfehlern scheitert.
final class LegacyEnvironmentConfigTest extends \PHPUnit\Framework\TestCase
{
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function testProductionEnvironmentDisablesDebugOutput(): void
{
// Der neue Prozess startet sauber ueber den Bootstrap aus phpunit.xml,
// ohne serialisierten globalen Zustand aus dem Hauptprozess.
define('APPLICATION_ENV', 'production');
$config = new LegacyEnvironmentConfig();
self::assertFalse($config->isDebugEnabled());
}
}
4. Was ein isolierter Test tatsaechlich kostet
Der Preis fuer Prozessisolation ist real und leicht messbar: Ein normaler Test innerhalb des gemeinsamen Prozesses laeuft oft in Bruchteilen einer Millisekunde bis wenigen Millisekunden. Ein Test mit @runInSeparateProcess braucht dagegen den vollen Start eines neuen PHP-Interpreters, inklusive Laden der Konfiguration, Initialisieren aller Extensions und, je nach Autoloader-Groesse, dem erneuten Aufbau der Klassen-Map. Das kann pro Test leicht 50 bis mehrere hundert Millisekunden zusaetzlich kosten, je nach Projektgroesse und Systemumgebung.
Bei einer Handvoll isolierter Tests faellt das kaum auf. Sobald aber zehn, zwanzig oder mehr Tests in einer Suite isoliert laufen, addiert sich das zu einer spuerbaren Verlangsamung der gesamten Pipeline, oft im Bereich mehrerer Sekunden bis Minuten. In CI-Umgebungen mit begrenzten Ressourcen und parallelen Jobs wirkt sich das besonders stark aus, weil jeder isolierte Test seinen eigenen Prozessstart-Overhead in voller Hoehe traegt, unabhaengig davon, wie trivial der eigentliche Testinhalt ist.
5. Alternativen, bevor man zu Prozessisolation greift
Bevor eine Testmethode mit @runInSeparateProcess versehen wird, lohnt sich die Frage, ob das eigentliche Problem nicht anders geloest werden kann. Fuer Konstanten, die eigentlich Konfigurationswerte sind, bietet sich haeufig eine Umstellung auf ein injizierbares Konfigurationsobjekt an, das den Wert als Parameter entgegennimmt statt ihn global zu definieren. Fuer statische Zustaende reicht oft schon eine reset-Methode, die explizit in tearDown aufgerufen wird, ganz ohne einen neuen Prozess zu benoetigen.
Auch Mocking-Bibliotheken fuer Funktionen und Zeit, etwa fuer date() oder time(), koennen manche Faelle loesen, die sonst nach Prozessisolation aussehen, indem sie den globalen PHP-Namensraum innerhalb des laufenden Prozesses gezielt ueberschreiben, ohne echten Zustand zu veraendern. Prozessisolation sollte immer die letzte Option sein, wenn alle anderen Wege ausgeschoepft sind, nicht die erste Reaktion auf einen risky-Hinweis wegen globalem Zustand.
// Statt einer globalen Konstante: ein injizierbares Konfigurationsobjekt.
final class EnvironmentConfig
{
public function __construct(private readonly string $environment)
{
}
public function isDebugEnabled(): bool
{
return $this->environment === 'development';
}
}
final class EnvironmentConfigTest extends \PHPUnit\Framework\TestCase
{
// Kein Prozessisolation noetig: der Wert wird injiziert, nicht global gesetzt.
public function testDevelopmentEnvironmentEnablesDebugOutput(): void
{
$config = new EnvironmentConfig('development');
self::assertTrue($config->isDebugEnabled());
}
}
6. Wechselwirkung mit paralleler Testausfuehrung
Viele Projekte beschleunigen ihre Testsuite zusaetzlich durch parallele Ausfuehrung, etwa mit ParaTest, das mehrere PHPUnit-Worker gleichzeitig in getrennten Prozessen startet. Auf den ersten Blick koennte man annehmen, dass isolierte Tests in einer ohnehin parallelen Umgebung keinen zusaetzlichen Overhead mehr verursachen, weil ja bereits mehrere Prozesse laufen. Tatsaechlich ist das Gegenteil der Fall: Jeder Worker-Prozess fuehrt seine zugewiesenen Tests weiterhin sequenziell aus, und ein als @runInSeparateProcess markierter Test startet innerhalb seines Workers zusaetzlich einen weiteren, verschachtelten Prozess.
Das bedeutet, dass sich der Performance-Overhead von Prozessisolation und Parallelisierung nicht gegenseitig aufhebt, sondern addiert. In der Praxis lohnt es sich deshalb, isolierte Tests gezielt auf wenige Worker zu konzentrieren oder in eine eigene, kleinere Testsuite auszulagern, damit ein einzelner langsamer Worker nicht die gesamte parallele Ausfuehrung ausbremst, waehrend die uebrigen Worker laengst fertig sind und untaetig auf ihn warten.
7. Faelle, in denen Prozessisolation wirklich unvermeidbar ist
Es gibt Situationen, in denen die Alternativen aus dem vorherigen Abschnitt schlicht nicht greifen. Ein typisches Beispiel ist das Testen von Bootstrap-Code, der selbst Konstanten definiert oder Erweiterungen laedt, etwa beim Testen eines eigenen Autoloaders oder eines Fehlerhandlers, der sich mit set_error_handler global im Prozess registriert. Auch das Testen von Code, der auf bestimmten ini_set-Werten wie memory_limit basiert und diese tatsaechlich veraendert, kann eine echte Prozessisolation erfordern, weil solche Einstellungen sich nicht sauber innerhalb des laufenden Prozesses zuruecksetzen lassen.
Ein weiterer legitimer Fall ist das Testen von Legacy-Anwendungen, bei denen ein grosser, historisch gewachsener Bootstrap-Prozess beim Laden automatisch Konstanten und globale Funktionen definiert, ohne dass ein Refactoring kurzfristig moeglich ist. In solchen Faellen ist Prozessisolation kein Zeichen schlechten Testdesigns, sondern eine pragmatische Antwort auf eine Codebasis, die (noch) nicht fuer isolierte Unit-Tests gebaut wurde. Wichtig ist, diese Faelle bewusst zu kennzeichnen, etwa mit einem Kommentar, der erklaert, warum die Isolation hier tatsaechlich unvermeidbar ist.
8. Strategien fuer Suiten mit vielen isolierten Tests
Wenn ein Projekt bereits viele isolierte Tests angesammelt hat, lohnt es sich, diese in einer eigenen Testsuite zu buendeln, die getrennt von der schnellen Unit-Test-Suite laeuft. So kann die schnelle Suite bei jedem lokalen Speichern oder in jedem Pull-Request-Check ausgefuehrt werden, waehrend die langsamere, isolierte Suite seltener laeuft, etwa nur auf dem Hauptbranch oder in einer naechtlichen Pipeline. Diese Trennung erhaelt die schnelle Feedback-Schleife fuer den Grossteil der Entwicklungsarbeit.
Zusaetzlich kann man die Anzahl isolierter Tests als eigene Metrik im Team beobachten, aehnlich wie die Gesamtlaufzeit der Suite. Steigt diese Zahl kontinuierlich, ist das ein Hinweis darauf, dass sich globale Abhaengigkeiten in der Codebasis eher vermehren als abbauen, und ein guter Anlass, gezielt an den betroffenen Modulen zu refactorn, statt die Isolation als dauerhafte Loesung zu akzeptieren.
9. Eine praktische Entscheidungshilfe
Um in der taeglichen Arbeit schnell entscheiden zu koennen, ob Prozessisolation angemessen ist, hilft eine einfache Reihenfolge: Zuerst pruefen, ob der globale Zustand durch Dependency Injection vermieden werden kann. Falls nicht, pruefen, ob ein einfacher Reset in tearDown ausreicht. Erst wenn beides nicht funktioniert, etwa weil eine echte PHP-Konstante oder ein Extension-Zustand betroffen ist, der sich im selben Prozess nicht zuruecksetzen laesst, ist @runInSeparateProcess die richtige Wahl.
Die folgende Tabelle fasst typische Szenarien und die jeweils passende Strategie zusammen, als schnelle Orientierung fuer Code-Reviews und fuer die eigene Testplanung, bevor man reflexartig zur Prozessisolation greift.
| Szenario | Globaler Zustand betroffen | Empfohlene Loesung | Prozessisolation noetig |
|---|---|---|---|
| Konfigurationswert je Umgebung | Nein, wenn injizierbar gemacht | Konfigurationsobjekt statt Konstante | Nein |
| Statische Property/Singleton | Ja, aber im selben Prozess resetbar | Reset in tearDown, spaeter Dependency Injection | Nein |
| Echte PHP-Konstante (define) | Ja, nicht neu definierbar | @runInSeparateProcess mit @preserveGlobalState disabled | Ja |
| Bootstrap/Error-Handler/ini_set | Ja, prozessweit und nicht zuruecksetzbar | @runInSeparateProcess, bewusst kommentiert | Ja |
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
Process Isolation in PHPUnit: Das Wichtigste auf einen Blick
Wirkung
@runInSeparateProcess startet fuer den Test einen komplett neuen PHP-Prozess und isoliert dadurch Konstanten und andere prozessweite Zustaende vollstaendig.
Kosten
Jeder isolierte Test kostet zusaetzliche 50 bis mehrere hundert Millisekunden durch den Start eines neuen Interpreters, das summiert sich bei vielen isolierten Tests spuerbar.
Vorrang der Alternativen
Dependency Injection statt globaler Konstanten und Reset in tearDown sollten immer zuerst geprueft werden, bevor Prozessisolation zum Einsatz kommt.
Legitime Faelle
Echte PHP-Konstanten, Bootstrap-Code und Legacy-Anwendungen, die sich kurzfristig nicht refactorn lassen, rechtfertigen bewusst eingesetzte Prozessisolation.