Skip to content
Article

Isolate Shared State Before Adding Parallel Test Workers

Parallel test runs break on shared state, not CPU count. Here is how we isolated databases, temp files, locks, and reports in a Symfony coverage run, locked only the truly shared fixtures, and merged coverage without hiding failures.
TLDR
  • Parallel test failures usually come from shared state, not too few CPUs.
  • Give each process its own database suffix, and each queued test its own TMPDIR, lock store, JUnit file, and coverage file.
  • Lock only the fixtures that must stay shared, and keep those locks outside the per-test TMPDIR.
  • Warm one-time installs first, build the queue from phpunit --list-tests, and merge coverage with phpcov after the run.
  • Let the test exit code win, and treat any test that passes only on rerun as a bug report.

When a mature Symfony test suite gets slow, adding workers looks like the obvious fix. It usually isn't the first fix. Parallel test runs break on shared state, not on CPU count: two processes that both think they own the same database, temp file, lock, or report will produce failures that have nothing to do with the code under test.

The rule we follow is simple. Give every worker, and ideally every queued test, its own database, temporary directory, lock store, report file, and coverage file. Serialize the few resources that truly must stay shared with a narrow, explicit lock. Warm one-time installs before you fan out. Then merge the outputs and keep the test exit code in charge.

In October 2026 we applied this to the coverage run of a retail ecommerce integration middleware: a Symfony and Doctrine application whose suite exercises CSV imports, queues, and order and inventory flows. The client isn't named here, and the code below is trimmed from the real script and config. The lesson carries over to most PHP suites.

Start with a shared-state inventory

Before touching the test command, list every resource the suite creates, reads, overwrites, or reports into. For each one, decide whether it can be unique per process or has to stay shared. Here is the inventory for this suite and what we did with each item.

Resource

What we did

Why it matters

Doctrine test database

Appended the runner's process (channel) number to the test database name

Fixtures and test data can't cross process boundaries

Temporary files

Set TMPDIR per queued test (coverage/tmp/{n})

Generated and cached files don't collide

Symfony Lock store

Pointed LOCK_DSN at a flock directory per queued test

The lock backend stops being accidental shared infrastructure

JUnit reports

One XML file per queued test

No process overwrites another's results

Coverage data

One .cov file per queued test, merged at the end

Each process records coverage independently

CSV import fixtures in a shared directory

An exclusive cross-process flock per fixture file

Tests that must modify and restore the same file take turns

An inventory like this is worth doing even if you never parallelize. Flaky tests usually point at unclear ownership of files and data, and the same blind spots tend to show up later in production. Finding them early is a normal part of our custom software development work.

Give every process its own environment

The runner here is liuggio/fastest, which reads a queue of tests from standard input and runs them across N processes. Its command template has two placeholders: {p} is the process (channel) number and {n} is a unique number for each queued test. Inside each process it also sets environment variables such as ENV_TEST_CHANNEL.

For the database, the Doctrine test config appends both the ParaTest convention and the fastest channel to the database name:

when@test:
    doctrine:
        dbal:
            # "TEST_TOKEN" is typically set by ParaTest
            # "ENV_TEST_CHANNEL" is typically set by liuggio/fastest ({p})
            dbname_suffix: '_test%env(default::TEST_TOKEN)%%env(default::ENV_TEST_CHANNEL)%'

The Symfony DoctrineBundle recipe already ships the TEST_TOKEN half. Adding the channel variable lets the same config work under either runner, and a plain single-process bin/phpunit run still gets the ordinary _test database because both variables default to empty.

For files, locks, and reports, the script goes one level finer and keys everything on {n}, so each queued test gets its own directory and output files:

FASTEST_ISOLATION_ENV="TMPDIR=\"$COVERAGE_BASE_DIR/coverage/tmp/{n}\" LOCK_DSN=\"flock://$COVERAGE_BASE_DIR/coverage/tmp/{n}/locks\"";
FASTEST_COMMAND="$COVERAGE_COMMAND_PREFIX ./bin/phpunit -c \"$PHPUNIT_CONFIG\" {} --colors=never --coverage-php $COVERAGE_BASE_DIR/coverage/php/{n}.cov --log-junit $COVERAGE_BASE_DIR/coverage/junit/{n}.xml";
FASTEST_EXECUTE="mkdir -p \"$COVERAGE_BASE_DIR/coverage/tmp/{n}/locks\" && $FASTEST_ISOLATION_ENV $FASTEST_COMMAND";

Keying on {n} instead of {p} costs a few extra directories and files. In return, no two test runs ever write to the same temp path, lock file, or report, even when a process starts its next test while the last one's leftovers are still on disk. We think that's the right default. Directories are cheap; chasing a collision isn't.

Lock only what has to stay shared

Some state can't be split without changing what a test verifies. In this suite, import tests write CSV fixtures into the application's var/import directory, because that's where the import commands read from. Two processes writing the same fixture file at once would corrupt each other's input.

The fix was a narrow lock per fixture file. Before a test writes or removes a fixture, the support trait takes an exclusive flock on a lock file named after that fixture. Teardown restores the original file and releases the lock. Trimmed:

// Shared across workers (do not use TMPDIR — coverage runner sets a per-method TMPDIR).
$lockDir = '/tmp/phpunit-import-fixture-locks';
if (!is_dir($lockDir)) {
    mkdir($lockDir, 0777, true);
}

$lockPath = $lockDir.'/'.preg_replace('/[^A-Za-z0-9._-]/', '_', $filename).'.lock';
$handle = fopen($lockPath, 'c+');
if (!flock($handle, LOCK_EX)) {
    fclose($handle);
    throw new \RuntimeException(sprintf('Unable to acquire import fixture lock "%s"', $lockPath));
}

$this->importCommandFixtureLocks[$filename] = $handle;

Where that lock directory lives is the whole point. Everything else in the run is isolated per test, including TMPDIR. A lock file under TMPDIR would be private to one process, so it would coordinate with nobody. A shared lock has to live somewhere every process can see.

Why one big lock is the wrong default

It's tempting to wrap a flaky area of the suite in one global lock. That hides the failure, throws away the parallelism, and leaves the real shared resource unnamed. Our rule: isolate by default, then lock the smallest shared thing that can't be isolated, and name the lock after it.

Warm one-time setup before you fan out

In projects that use the Symfony PHPUnit Bridge's bin/phpunit wrapper, the first run can install PHPUnit itself. If several processes hit that cold install at once, they race. So the script runs the bridge once, serially, before starting any workers:

# Warm the Symfony PHPUnit bridge install once so parallel workers do not race on first boot.
$PHP_BINARY ./bin/phpunit -c "$PHPUNIT_CONFIG" --version >/dev/null;

It's one line, and it removes a whole category of first-run failures on fresh CI runners. Any shared bootstrap step deserves the same treatment. In this script the phpcov PHAR download and the generated coverage-only phpunit.xml (which also silences deprecation output) both happen up front for the same reason.

Let PHPUnit build the queue

The usual fastest example pipes find tests/ -name "*Test.php" into the runner. That also picks up abstract base classes whose file names end in Test.php, which aren't runnable tests. The script asks PHPUnit instead. bin/phpunit --list-tests returns only the tests PHPUnit will actually run, and the script reduces that list to unique test files. An opt-in flag, RUN_TESTS_WITH_COVERAGE_BY_METHOD=1, switches to one queue entry per test method using --filter, for when one huge test class becomes the long pole.

The process count comes from a PARALLEL_PROCESSES variable when it's set, then nproc, then sysctl -n hw.ncpu on macOS, then a fallback of 4. The same script works on developer laptops and CI runners without edits.

Treat reruns as bug reports

The script runs fastest with --rerun-failed, which re-runs a failed test. That keeps one unlucky collision from failing a pipeline. It also means a race can hide inside a green build.

Our position: a test that passes only on rerun is a bug report, not a pass. It points at an order dependency, an unclean teardown, a resource that slipped through the inventory, or a dependency the test shouldn't need. A related cleanup landed the same day. Two controller tests had been quietly assuming a reachable database, and they now stub the user manager or repository they need, so they test the controller instead of the database connection.

The script also passes --preserve-order. fastest randomizes the queue by default, and random order is good at flushing out order dependence, while a fixed order makes runs easier to compare. If you keep the fixed order, schedule a randomized run now and then to catch what it hides.

Merge coverage without letting it mask failures

Each queued test writes serialized coverage with --coverage-php. After the queue drains, phpcov merge combines every .cov file into HTML, Cobertura, and text reports. The script uses PCOV in CI and Xdebug's coverage mode locally, the split we described in our earlier post on adding CI test coverage to Symfony integration middleware with PCOV and Xdebug.

The order of the exit codes matters:

./vendor/bin/fastest -vvv --ansi --process "$PROCESS_COUNT" --preserve-order --rerun-failed --no-errors-summary "$FASTEST_EXECUTE" < "$QUEUE_FILE";
FASTEST_EXIT=$?;

$PHP_BINARY "$PHPCOV_PHAR" merge \
  --html "$COVERAGE_BASE_DIR/coverage/html" \
  --cobertura "$COVERAGE_BASE_DIR/coverage/cobertura.xml" \
  --text "$COVERAGE_BASE_DIR/coverage/coverage.txt" \
  "$COVERAGE_BASE_DIR/coverage/php";
PHPCOV_EXIT=$?;

if [ "$FASTEST_EXIT" -ne 0 ]; then
  exit "$FASTEST_EXIT";
fi
exit "$PHPCOV_EXIT";

Coverage still merges after a failed run, so the reports exist when you need them most. But the test result wins: a merged report can never turn a failed suite into a passing job. On the CI side, the JUnit report path in .gitlab-ci.yml changed from one file to the pattern coverage/junit/*.xml. GitLab accepts filename patterns for JUnit reports, so the merge request test summary still shows results from every process.

A practical sequence

  1. Inventory every database, directory, lock backend, fixture, report, and coverage file the suite touches.

  2. Make each one per process or per test: database suffix, TMPDIR, lock store, JUnit file, coverage file.

  3. Put narrow cross-process locks on what must stay shared, in a location every process can see.

  4. Run one-time installs and downloads before starting workers.

  5. Build the queue from phpunit --list-tests, not find.

  6. Make the process count configurable, with sensible detection.

  7. Merge coverage after the run, and exit with the test runner's status first.

  8. Log every rerun and fix the test behind it.

None of this is exotic, but it's easy to get half right. The common failure is bumping a worker count, watching the suite go flaky, and then losing weeks to failures nobody can reproduce. The inventory is the part that pays off, and it goes faster with engineers who already know where shared state hides in a Symfony app. That's everyday Symfony development and application support work for us.

How we know

This post is based on our own change to the coverage script of a retail ecommerce integration middleware, committed on October 14, 2026. It covered the switch to liuggio/fastest, per-test temp, lock, JUnit and coverage paths, the Doctrine database suffix, the cross-process fixture locks, the bridge warm-up, the phpcov merge, and the GitLab report change. A follow-up commit the same day removed two tests' hidden database dependency. We checked tool behavior against the fastest and phpcov READMEs, the Symfony DoctrineBundle recipe, and GitLab's CI artifact reports documentation on October 20, 2026.

Last reviewed: October 20, 2026.

Limits

We didn't record before-and-after timings, so this post makes no claims about speed. How much parallelism helps depends on your suite, your database, and your runner hardware. The setup was built for one middleware suite running in GitLab CI; ParaTest or GitHub Actions setups need different wiring, although the inventory applies unchanged. And isolation won't fix a test with unclear dependencies. If reruns keep happening after you isolate state, the rerun is telling you where to look next.

Sources

Get your test suite ready for parallel runs

If CI is slow and parallel runs feel risky, start with the shared-state inventory. If you'd like a second set of eyes on the test, integration, and release workflow behind a commerce system, let's talk through your engineering strategy.

Drag to pan. Use +/− or Ctrl/Cmd + scroll to zoom. Pinch to zoom on touch devices.