Process Isolation in PHPUnit: When It Is Necessary and What It Costs
AI generated
@test
assert
PHPUnit · Process Isolation · PHP
Process Isolation in PHPUnit
@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.

15 min read Process Isolation · Global State · Performance PHPUnit 10 · 11 · PHP 8.x

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.

11. FAQ: Process Isolation in PHPUnit: The Essentials at a Glance

1What exactly does @runInSeparateProcess do technically?
PHPUnit serializes the test context, starts a completely new PHP process, deserializes the context there, and runs the test in that fresh process. Anything that happens afterward in terms of global state stays fully isolated from the main process.
2When should I set @preserveGlobalState to disabled?
Whenever the automatically transferred state from the main process itself causes problems, for example non-serializable objects, or when the test is already cleanly initialized through the regular bootstrap and does not need any transferred state.
3How much does process isolation actually slow down a test suite?
Each isolated test typically adds 50 to several hundred milliseconds, depending on project size and autoloader. Barely noticeable with a few tests, but with twenty or more isolated tests it adds up to noticeable seconds to minutes.
4Can I isolate an entire test class instead of individual methods?
Yes, the annotation can be placed at the class level, in which case every test method in that class runs in its own new process. That is convenient but also multiplies the performance overhead by the number of methods.
5Is process isolation a sign of bad test design?
Often yes, when it compensates for avoidable global state that could be solved through dependency injection. In legitimate cases such as real PHP constants or legacy bootstrap code, it is instead a pragmatic, justified solution.
6Is there an alternative to real constants for configuration values?
Yes, an injectable configuration object that receives the value as a constructor parameter solves most cases where a global constant used to be the tool of choice, without any process isolation.
7Should I group isolated tests into their own test suite?
That is a sensible strategy for projects with many isolated tests: a fast unit test suite runs on every commit, a slower isolated suite runs less often, for example only on the main branch.
8Does @runInSeparateProcess work with data providers?
Yes, every test instance generated by the data provider then runs in its own isolated process, which multiplies the performance overhead by the number of data sets.
9Why can't PHP constants simply be reset?
That is a deliberate design decision in PHP: once a constant is set with define(), it is immutable for the lifetime of the process, there is no built-in function to remove or redefine it.
10How do I tell if my project has too many isolated tests?
A rising overall suite runtime despite a stable test count is a strong indicator. It is also worth explicitly counting tests marked with @runInSeparateProcess as a team metric.