Test Parallelization Gotchas
debt(d9/e5/b5/t7)
Closest to 'silent in production until users hit it' (d9), because parallel test flakiness is non-deterministic and timing-dependent — paratest/pest/phpunit don't flag shared state, and the regex hints only catch obvious patterns. Failures only surface when two workers happen to collide.
Closest to 'touches multiple files / significant refactor in one component' (e5), because the quick_fix requires per-worker DB/temp-dir/port isolation plus tearDown restoration of global state across many test files, not a single-line swap.
Closest to 'persistent productivity tax' (b5), because applies_to spans web/cli/queue/library and isolation discipline must be maintained across every new test; shared-state habits keep recurring and slow the whole testing workstream.
Closest to 'serious trap' (t7), because the misconception (passing in isolation implies parallel-safe) directly contradicts how developers reason about test correctness — the 'obvious' assumption is wrong and the failure mode is hidden behind timing.
Also Known As
TL;DR
Explanation
Running tests in parallel cuts suite time dramatically, but it exposes assumptions that sequential execution hid. Tests that quietly shared a database row, a temp file path, a global singleton, an environment variable, or a fixed port now collide with sibling tests running in other workers. The symptoms look like flakiness: passes locally, fails in CI, fails differently on each run, fails only when the suite is fast enough to interleave operations.
The common culprits fall into a few categories. Shared mutable state: static properties, container singletons, module-level caches, environment variables mutated with putenv. Shared external resources: one database, one Redis instance, one filesystem directory, one fixed port number — all racing workers stomp on each other. Time-sensitive logic: tests asserting on 'now' that drift when CPU scheduling delays one worker. Order dependence: a test that only passes because an earlier test seeded data, now run in isolation by a different worker. Resource exhaustion: parallel workers spawning real HTTP connections, browser instances, or DB connections beyond pool limits, producing timeout errors that masquerade as logic failures.
The fix is not to disable parallelism but to make each test self-contained. Give each worker its own database (PARATEST_TOKEN, TEST_TOKEN_DB suffixes), its own temp directory, its own port from a dynamic allocator. Reset singletons between tests. Avoid mutating process-global state, or wrap it in a teardown that restores the original. Inject the clock instead of reading wall time. Treat parallel safety as a property of the test, not the runner — if a test would break when run twice simultaneously against the same process, it is broken regardless of how the CI happens to schedule it today.
Common Misconception
Why It Matters
Common Mistakes
- Sharing one database across workers without per-worker schemas or token-suffixed names, causing inserts and truncations to collide.
- Writing to a fixed temp path like /tmp/test-output.json instead of a per-process directory, so concurrent tests overwrite each other.
- Mutating static properties, container singletons, or putenv values without resetting them in teardown.
- Binding to a hardcoded port (8080, 6379) instead of allocating a free port per worker.
- Relying on test execution order — for example, a test that assumes a fixture row exists from a previous test that may now run in a different worker.
Avoid When
- Test suite is small enough (under ~30 seconds) that parallel execution provides no meaningful speedup.
- Tests integrate with an external system that cannot be sharded or mocked, where parallelism would only multiply contention.
- Codebase has heavy static state and refactoring for parallel safety would exceed the time savings.
When To Use
- Suite runtime is a bottleneck for CI feedback or pre-commit hooks.
- Tests can be isolated per worker via database tokens, temp directories, or container-per-worker setups.
- You already battle flaky tests caused by shared state — making the suite parallel-safe forces the underlying isolation fixes.
Code Examples
// Two parallel workers, one shared resource:
class ReportTest extends TestCase {
public function testGeneratesCsv(): void {
$path = '/tmp/report.csv'; // Fixed path - workers collide
Report::writeTo($path);
$this->assertFileExists($path);
$this->assertStringContainsString('total', file_get_contents($path));
}
public function testCachesSingleton(): void {
Cache::$instance = null; // Static - bleeds between workers
putenv('FEATURE_X=1'); // Global env mutation
$this->assertTrue(FeatureFlags::enabled('x'));
// No teardown - next test inherits FEATURE_X=1
}
}
// Per-worker isolation, no shared globals:
class ReportTest extends TestCase {
private string $tmpDir;
private ?string $originalFeatureX;
protected function setUp(): void {
// Unique dir per worker + per test
$token = getenv('TEST_TOKEN') ?: (string) getmypid();
$this->tmpDir = sys_get_temp_dir() . "/report-{$token}-" . uniqid();
mkdir($this->tmpDir, 0700, true);
$this->originalFeatureX = getenv('FEATURE_X') ?: null;
}
protected function tearDown(): void {
// Restore globals so siblings see clean state
$this->originalFeatureX === null
? putenv('FEATURE_X')
: putenv("FEATURE_X={$this->originalFeatureX}");
Cache::reset();
}
public function testGeneratesCsv(): void {
$path = $this->tmpDir . '/report.csv';
Report::writeTo($path);
$this->assertFileExists($path);
}
}