Parallel test runs no longer race over compiled view directories
Contributed by joshdaugherty
Part of the framework v13.35.0 release
Parallel test runs that collide with each other no longer crash the framework's view-path bookkeeping. PR #61786 fixes a check-then-act race in the TestViews trait, where two simultaneous parallel runs of the same project could fail with mkdir(): File exists during set-up or an UnexpectedValueException during tear-down. The change is narrowly scoped to the two ParallelTesting callbacks in src/Illuminate/Testing/Concerns/TestViews.php: set-up now creates the directory in a way that tolerates it already existing, and tear-down treats a directory that vanished as already cleaned up. There is no API change, no configuration change, and no behavioral difference for a single parallel run — if you are upgrading from v13.34.0, pulling this patch simply removes a source of flaky parallel test failures.
The race: two runs, same token, check-then-act
The TestViews trait registers setUpProcess and tearDownProcess callbacks with ParallelTesting so each parallel worker gets its own compiled view directory, named after ParallelTesting::token() — the worker index. Because the token is only the worker index, two parallel runs of the same project running at the same time resolve the same directory paths. That happens easily in practice: a local run while a CI job works in the same checkout, or two CI jobs sharing a workspace.
# Terminal 1 and Terminal 2 (or CI) on the same project:
php artisan test --parallel
php artisan test --parallel
Both callbacks used check-then-act logic, so the run that loses the timing fails:
- Set-up:
File::ensureDirectoryExists()checksisDirectory(), then callsmkdir()without warning suppression. If the other run creates the directory in between, you getErrorException: mkdir(): File exists. - Tear-down:
File::deleteDirectory()checksisDirectory(), then constructs aFilesystemIterator. If the other run deletes the directory in between, the iterator throwsUnexpectedValueException.
Neither failure indicates a real problem — each is the framework fighting itself over work the other run already did.
What changed
Only the two callbacks in the trait were touched:
// Before (v13.34.0)
ParallelTesting::setUpProcess(function () {
if ($path = $this->parallelSafeCompiledViewPath()) {
File::ensureDirectoryExists($path); // check, then mkdir()
}
});
ParallelTesting::tearDownProcess(function () {
if ($path = $this->parallelSafeCompiledViewPath()) {
File::deleteDirectory($path); // check, then FilesystemIterator
}
});
// After
ParallelTesting::setUpProcess(function () {
if ($path = $this->parallelSafeCompiledViewPath()) {
File::makeDirectory($path, 0755, true, true); // recursive, force
}
});
ParallelTesting::tearDownProcess(function () {
if ($path = $this->parallelSafeCompiledViewPath()) {
try {
File::deleteDirectory($path);
} catch (UnexpectedValueException) {
// A concurrent parallel run removed the directory first,
// which is the state we want...
}
}
});
Passing force: true to makeDirectory() removes the check-then-act window on set-up entirely: whether the directory exists or not, the call succeeds. On tear-down, UnexpectedValueException is precisely what FilesystemIterator throws when the directory disappeared mid-flight, so catching it converts "another run already did my job" into success.
Proof by deterministic test
Races are notoriously hard to test because you cannot rely on timing. The two new tests in TestViewsTest remove luck from the equation: each replaces the container's files binding with a Filesystem whose isDirectory() answers as it would have just before the other run acted, while the real directory sits in the other run's state. A custom error handler (withWarningsAsExceptions) turns the unsuppressed mkdir() warning into an ErrorException, so the old code cannot quietly get away with it.
Against the 13.x implementation, the set-up test fails with ErrorException: mkdir(): File exists and the tear-down test fails with UnexpectedValueException from FilesystemIterator. With this patch, both pass, and the full tests/Testing suite is green (414 tests, verified on PHP 8.4 / Windows 11).
Why it matters
These failures looked like random, environment-specific breakage: the same command that failed seconds ago passes on retry, and nothing in your application code is wrong. Because the window is a fraction of a second, the problem surfaces more often on faster CI runners with heavier parallelism, where overlapping runs and matrix jobs against shared workspaces are common. Making the callbacks idempotent means parallel testing infrastructure can no longer be the flaky part of your pipeline.
Upgrade impact
Practically none, which is what you want from a fix at this layer:
- No migrations, config, or environment changes;
token()returns what it always did, and compiled views go to the same per-worker directories. - A single parallel run behaves exactly as it did in v13.34.0.
- If you registered your own
setUpProcess/tearDownProcesscallbacks, they are untouched — but if you copied the old check-then-act pattern elsewhere, this PR is a good pattern to mirror: create idempotently, tolerate disappearance. - If you fork or pin
TestViews, rebase onto this change.
Takeaways
- PR #61786 fixes concurrent parallel test runs crashing over the same token-based compiled view directories.
- Set-up now uses
File::makeDirectory($path, 0755, true, true)instead of a check-then-actensureDirectoryExists(). - Tear-down catches
UnexpectedValueExceptionand treats a vanished directory as success. - Behavior for a single parallel run is unchanged; upgrading from v13.34.0 requires no action beyond pulling the patch.
- Two new deterministic tests cover both halves of the race; they fail on the old code and pass with the fix.
- Changelog category: Fix (id 3).