π€ AI-generated β created with Claude Code, reviewed by @Tabrisrp. Discovered while migrating wp-media/mcp-oauth to wp-media/phpunit v3.2.
Summary
The wpmedia-phpunit binary is incompatible with PHPUnit's @runInSeparateProcess isolation: all @runInSeparateProcess tests fail when run through the binary, while identical tests pass under a direct vendor/bin/phpunit invocation with the same arguments.
Steps to reproduce / Evidence
Reproduction in a consumer plugin (wp-media/mcp-oauth):
- A test class with 9
@runInSeparateProcess + @preserveGlobalState disabled data sets
- Via
composer test-unit (runs wpmedia-phpunit unit): 0/9 pass (Test was run in child process and ended unexpectedly)
- Via direct
vendor/bin/phpunit --testsuite unit --colors=always --configuration Tests/Unit/phpunit.xml.dist: 9/9 pass
- Same effective arguments; identical test code
PHPUnit instrumentation evidence:
- Instrumenting
vendor/phpunit/phpunit/src/Util/PHP/Template/TestCaseMethod.tpl with counters showed the isolated child dies inside the {included_files} block: the included-file count grows from 58 β 142 in the passing (direct) run, whereas the binary-run child never survives that block to reach the configured bootstrap require.
Root cause
The binary (wpmedia-phpunit at repo root) and src/BootstrapManager.php bootstrap PHPUnit by require-ing vendor/bin/phpunit into the currently running PHP process (see BootstrapManager::runTestSuite(), the require_once "{$root}/bin/phpunit" line).
The failure chain:
wpmedia-phpunit and src/BootstrapManager.php are added to get_included_files() when loaded into the parent process.
- For
@runInSeparateProcess tests, PHPUnit serializes the parent's included-files list into each child and re-requires them (PHPUnit\Util\GlobalState::getIncludedFilesAsString()).
- The child re-
requires wpmedia-phpunit.
- Because that script begins with a
#!/usr/bin/env php shebang and, when included, self-executes (it calls BootstrapManager::setupConstants() and BootstrapManager::runTestSuite() at the top level), re-including it in the child re-runs the whole bootstrap manager and emits the shebang line to the child's STDOUT.
- That corrupts the serialized child result the parent reads back β
Test was run in child process and ended unexpectedly.
- Every isolated data set fails.
A direct vendor/bin/phpunit run passes because its included files are all inert library files β harmless to re-require.
Proposed fix
Two options; the second is recommended as the robust, long-term solution.
Option 1 β Minimal / targeted
In BootstrapManager, before running the suite, register the binary entrypoints into PHPUnit's isolation exclusion list so they are not re-required in isolated children:
$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'][] = realpath( $wpmedia_phpunit_binary_path );
$GLOBALS['__PHPUNIT_ISOLATION_EXCLUDE_LIST'][] = realpath( __DIR__ . '/BootstrapManager.php' );
PHPUnit already honors this list in vendor/phpunit/phpunit/src/Util/GlobalState.php (getIncludedFilesAsString() skips listed files). Composer's bin-proxy already populates it for the real phpunit entry; the binary's own entrypoints simply need to be added.
- Pros: minimal change; reuses PHPUnit's existing mechanism.
- Cons: does not address the structural issue of loading PHPUnit into-process.
Option 2 β Structural / robust (recommended)
Run PHPUnit as a genuine subprocess (proc_open/passthru) instead of require-ing it into the runner process, so the binary and BootstrapManager never enter the test process's included-files set at all. This mirrors how native phpunit runs.
- Pros: eliminates the root cause; robust against future PHPUnit versions and other edge cases.
- Cons: larger refactor; requires careful subprocess lifecycle/error handling.
Impact
@runInSeparateProcess is common in WordPress plugin tests (e.g. tests that define() constants such as WP_DEBUG / WP_DEBUG_LOG). Affected consumers cannot use the README-recommended wpmedia-phpunit binary and must fall back to direct phpunit.
- Dogfooding/README inconsistency worth noting: the README recommends
"test-unit": "wpmedia-phpunit unit", but this library's own composer.json scripts invoke vendor/bin/phpunit --configuration ... directly β i.e. they sidestep the binary and therefore this bug.
Additional notes
Minor related papercut: BootstrapManager::getConfigArgv() hardcodes --colors=always, which emits raw ANSI escape codes when output is piped to a non-TTY (CI logs / file capture). Consider --colors=auto or making it overridable.
Summary
The
wpmedia-phpunitbinary is incompatible with PHPUnit's@runInSeparateProcessisolation: all@runInSeparateProcesstests fail when run through the binary, while identical tests pass under a directvendor/bin/phpunitinvocation with the same arguments.Steps to reproduce / Evidence
Reproduction in a consumer plugin (wp-media/mcp-oauth):
@runInSeparateProcess+@preserveGlobalState disableddata setscomposer test-unit(runswpmedia-phpunit unit): 0/9 pass (Test was run in child process and ended unexpectedly)vendor/bin/phpunit --testsuite unit --colors=always --configuration Tests/Unit/phpunit.xml.dist: 9/9 passPHPUnit instrumentation evidence:
vendor/phpunit/phpunit/src/Util/PHP/Template/TestCaseMethod.tplwith counters showed the isolated child dies inside the{included_files}block: the included-file count grows from 58 β 142 in the passing (direct) run, whereas the binary-run child never survives that block to reach the configuredbootstraprequire.Root cause
The binary (
wpmedia-phpunitat repo root) andsrc/BootstrapManager.phpbootstrap PHPUnit byrequire-ingvendor/bin/phpunitinto the currently running PHP process (seeBootstrapManager::runTestSuite(), therequire_once "{$root}/bin/phpunit"line).The failure chain:
wpmedia-phpunitandsrc/BootstrapManager.phpare added toget_included_files()when loaded into the parent process.@runInSeparateProcesstests, PHPUnit serializes the parent's included-files list into each child and re-requires them (PHPUnit\Util\GlobalState::getIncludedFilesAsString()).requireswpmedia-phpunit.#!/usr/bin/env phpshebang and, when included, self-executes (it callsBootstrapManager::setupConstants()andBootstrapManager::runTestSuite()at the top level), re-including it in the child re-runs the whole bootstrap manager and emits the shebang line to the child's STDOUT.Test was run in child process and ended unexpectedly.A direct
vendor/bin/phpunitrun passes because its included files are all inert library files β harmless to re-require.Proposed fix
Two options; the second is recommended as the robust, long-term solution.
Option 1 β Minimal / targeted
In
BootstrapManager, before running the suite, register the binary entrypoints into PHPUnit's isolation exclusion list so they are not re-required in isolated children:PHPUnit already honors this list in
vendor/phpunit/phpunit/src/Util/GlobalState.php(getIncludedFilesAsString()skips listed files). Composer's bin-proxy already populates it for the real phpunit entry; the binary's own entrypoints simply need to be added.Option 2 β Structural / robust (recommended)
Run PHPUnit as a genuine subprocess (
proc_open/passthru) instead ofrequire-ing it into the runner process, so the binary andBootstrapManagernever enter the test process's included-files set at all. This mirrors how native phpunit runs.Impact
@runInSeparateProcessis common in WordPress plugin tests (e.g. tests thatdefine()constants such asWP_DEBUG/WP_DEBUG_LOG). Affected consumers cannot use the README-recommendedwpmedia-phpunitbinary and must fall back to direct phpunit."test-unit": "wpmedia-phpunit unit", but this library's owncomposer.jsonscripts invokevendor/bin/phpunit --configuration ...directly β i.e. they sidestep the binary and therefore this bug.Additional notes
Minor related papercut:
BootstrapManager::getConfigArgv()hardcodes--colors=always, which emits raw ANSI escape codes when output is piped to a non-TTY (CI logs / file capture). Consider--colors=autoor making it overridable.