@runInSeparateProcess and @preserveGlobalState: when they are genuinely needed and what they cost
Some tests can only be written cleanly if PHPUnit runs them in a completely new PHP process: for example when legacy code defines constants that cannot be redefined, or when static state is so deeply entangled that a reset in tearDown is not enough. @runInSeparateProcess solves this reliably, but not for free: every isolated test starts its own PHP interpreter, which can noticeably drive up a suite's runtime if it is used too liberally.
Table of Contents
- 1. Why PHP tests can need a process of their own
- 2. @runInSeparateProcess in practice
- 3. @preserveGlobalState and why it usually needs to be disabled
- 4. What an isolated test actually costs
- 5. Alternatives before reaching for process isolation
- 6. Interaction with parallel test execution
- 7. Cases where process isolation really is unavoidable
- 8. Strategies for suites with many isolated tests
- 9. A practical decision guide
- 10. Summary
- 11. FAQ
1. Why PHP tests can need a process of their own
By default, PHPUnit runs every test in a suite inside the same PHP process. That is fast, because no new interpreter has to start, but it has an important consequence: anything that is truly global in PHP, such as once-defined constants, loaded classes, or certain extension state, persists for the entire duration of the test run. Unlike static properties, which can be reset in tearDown, some PHP constructs simply have no way to be returned to their original state within the same process.
The classic example is a constant set with define(). PHP does not allow redefining or removing an already defined constant. If two tests need the same constant with different values, for example to simulate two different environments, there is no solution within a single process. This is exactly the case PHPUnit's @runInSeparateProcess annotation addresses, running the affected test in a completely new, freshly started PHP process.
2. @runInSeparateProcess in practice
The @runInSeparateProcess annotation (or the corresponding RunInSeparateProcess attribute in newer PHPUnit versions) marks a single test method or an entire test class. PHPUnit then serializes the test context, starts a new PHP process, deserializes the context there, and runs the test. Anything that happens in this process in terms of constants, global state, or loaded extensions stays completely isolated from the rest of the suite.
This makes it possible to write tests for legacy code that, say, needs to check a constant like APPLICATION_ENV with different values, without one test affecting the next. It matters that the isolation applies only to the marked test: other tests in the same class continue running in the regular, shared process, unless the annotation is placed at the class level, applying to every method in it.
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 and why it usually needs to be disabled
By default, when starting the isolated process, PHPUnit tries to carry over as much of the current global state as possible, such as autoloader registrations and certain superglobals, into the new process. That is convenient when the test needs access to classes from the main project, but in certain situations it can itself cause problems, for example when objects in global state are not serializable or hold resources such as open file handles.
The @preserveGlobalState annotation controls this behavior explicitly. With the value disabled, no state is transferred, and the new process starts completely unloaded, but has to take care of everything it needs itself, typically through the regular bootstrap defined in phpunit.xml. In practice, disabled is often the more robust choice, because automatic state transfer easily fails with serialization errors on more complex object graphs.
final class LegacyEnvironmentConfigTest extends \PHPUnit\Framework\TestCase
{
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function testProductionEnvironmentDisablesDebugOutput(): void
{
// The new process starts cleanly through phpunit.xml's bootstrap,
// without serialized global state from the main process.
define('APPLICATION_ENV', 'production');
$config = new LegacyEnvironmentConfig();
self::assertFalse($config->isDebugEnabled());
}
}
4. What an isolated test actually costs
The price of process isolation is real and easy to measure: a normal test inside the shared process often runs in fractions of a millisecond to a few milliseconds. A test with @runInSeparateProcess, by contrast, needs the full startup of a new PHP interpreter, including loading configuration, initializing every extension, and, depending on autoloader size, rebuilding the class map. That can easily add 50 to several hundred milliseconds per test, depending on project size and system environment.
With a handful of isolated tests this barely registers. But once ten, twenty, or more tests in a suite run isolated, it adds up to a noticeable slowdown of the entire pipeline, often in the range of several seconds to minutes. In CI environments with limited resources and parallel jobs this hits especially hard, because every isolated test carries its full process-startup overhead regardless of how trivial the actual test content is.
5. Alternatives before reaching for process isolation
Before marking a test method with @runInSeparateProcess, it is worth asking whether the underlying problem can be solved differently. For constants that are really configuration values, switching to an injectable configuration object that accepts the value as a parameter instead of defining it globally often works well. For static state, a reset method explicitly called in tearDown is frequently enough, without needing a new process at all.
Mocking libraries for functions and time, for example for date() or time(), can also resolve some cases that otherwise look like they need process isolation, by overriding the global PHP namespace within the running process in a targeted way, without actually changing real state. Process isolation should always be the last option once every other route is exhausted, not the first reaction to a risky warning about global state.
// Instead of a global constant: an injectable configuration object.
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
{
// No process isolation needed: the value is injected, not set globally.
public function testDevelopmentEnvironmentEnablesDebugOutput(): void
{
$config = new EnvironmentConfig('development');
self::assertTrue($config->isDebugEnabled());
}
}
6. Interaction with parallel test execution
Many projects further speed up their test suite through parallel execution, for example with ParaTest, which starts several PHPUnit workers simultaneously in separate processes. At first glance one might assume that isolated tests in an already parallel environment cause no additional overhead, since multiple processes are running anyway. In fact the opposite is true: each worker process still runs its assigned tests sequentially, and a test marked @runInSeparateProcess starts yet another, nested process within its worker.
That means the performance overhead of process isolation and parallelization does not cancel out, it adds up. In practice it is worth deliberately concentrating isolated tests on a few workers or moving them into their own, smaller test suite, so a single slow worker does not drag down the entire parallel run while the other workers have long finished and sit idle waiting for it.
7. Cases where process isolation really is unavoidable
There are situations where the alternatives from the previous section simply do not apply. A typical example is testing bootstrap code that itself defines constants or loads extensions, for instance when testing a custom autoloader or an error handler that registers itself globally in the process via set_error_handler. Testing code that relies on specific ini_set values such as memory_limit and actually changes them can also require true process isolation, because such settings cannot be cleanly reset within the running process.
Another legitimate case is testing legacy applications where a large, historically grown bootstrap process automatically defines constants and global functions on load, with no short-term refactor in sight. In such cases, process isolation is not a sign of poor test design but a pragmatic response to a codebase that is not (yet) built for isolated unit tests. It matters to mark these cases deliberately, for example with a comment explaining why isolation is genuinely unavoidable here.
8. Strategies for suites with many isolated tests
If a project has already accumulated many isolated tests, it is worth grouping them into their own test suite that runs separately from the fast unit test suite. That way the fast suite can run on every local save or in every pull request check, while the slower, isolated suite runs less often, for example only on the main branch or in a nightly pipeline. This separation preserves the fast feedback loop for the bulk of development work.
In addition, the number of isolated tests can be tracked as its own metric by the team, similar to overall suite runtime. A continuous rise in this number is a signal that global dependencies in the codebase are increasing rather than shrinking, and a good reason to refactor the affected modules deliberately instead of accepting isolation as a permanent solution.
9. A practical decision guide
To decide quickly in day-to-day work whether process isolation is appropriate, a simple order of checks helps: first check whether global state can be avoided through dependency injection. If not, check whether a simple reset in tearDown is enough. Only once neither works, for example because a real PHP constant or extension state is involved that cannot be reset within the same process, is @runInSeparateProcess the right choice.
The table below summarizes typical scenarios and the matching strategy for each, as a quick reference for code review and for planning tests before reaching for process isolation reflexively.
| Scenario | Global State Involved | Recommended Fix | Process Isolation Needed |
|---|---|---|---|
| Per-environment config value | No, once made injectable | Configuration object instead of constant | No |
| Static property/singleton | Yes, but resettable in-process | Reset in tearDown, later dependency injection | No |
| Real PHP constant (define) | Yes, cannot be redefined | @runInSeparateProcess with @preserveGlobalState disabled | Yes |
| Bootstrap/error handler/ini_set | Yes, process-wide and not resettable | @runInSeparateProcess, deliberately commented | Yes |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
Process Isolation in PHPUnit: The Essentials at a Glance
Effect
@runInSeparateProcess starts a completely new PHP process for the test, fully isolating constants and other process-wide state.
Cost
Every isolated test costs an extra 50 to several hundred milliseconds due to interpreter startup, which adds up noticeably with many isolated tests.
Priority of alternatives
Dependency injection instead of global constants and reset in tearDown should always be checked first, before reaching for process isolation.
Legitimate cases
Real PHP constants, bootstrap code, and legacy applications that cannot be refactored quickly justify deliberately applied process isolation.