skip to content

A PHPUnit 13 suite passes in default order but fails intermittently with executionOrder="random"; how do you reproduce the failure and find its cause?

level: seniorimportance: should knowfreq 30%

answer

  1. order exposes hidden coupling
  2. Random Seed line in the header
  3. --random-order-seed replays the order
  4. static properties and singletons leak
  5. backupStaticProperties as a diagnostic

basics

~20 s

Random order exposes tests that depend on state another test left behind. Take the Random Seed PHPUnit prints, replay it with --random-order-seed, find the test that runs earlier and leaks state, and make each test set up its own.

solid answer

~40 s

With `executionOrder="random"` (or `--order-by random`), PHPUnit shuffles tests and prints `Random Seed: <n>` in the run header; the seed defaults to the current time. A test that fails only in some orders depends on state another test leaves behind or cleans up: static properties, singletons and registries, `$_SERVER`/`$_ENV`, `date_default_timezone_set()`, files, or database rows. Reproduce with `--order-by random --random-order-seed <n>`, then use `--debug` to see which tests ran before the failure and narrow it down. Turning on `backupStaticProperties` or `backupGlobals` for a run, plus `beStrictAboutChangesToGlobalState`, makes PHPUnit report which test changed what. The fix is isolation - reset state in `setUp()`/`tearDown()`, inject dependencies instead of using static state - not pinning the order. `#[Depends]` chains stay intact because dependency resolution is on by default.

code

bash · 5 lines
bash
# CI log header showed:  Random Seed:   1758723977
vendor/bin/phpunit --order-by random --random-order-seed 1758723977

# same order, with the event stream to see what ran before the failure
vendor/bin/phpunit --order-by random --random-order-seed 1758723977 --debug

go deeper

for a junior

Recall that random order shuffles tests and prints a seed, and that the seed can be passed back to replay the same order.

for a middle

Explain why shared process state - static properties, superglobals, time zone - makes tests order-dependent and how --debug shows what ran before.

for a senior

Walk through the diagnosis: replay the seed, bisect the earlier tests, use backups with the global-state check, then fix isolation in the code.

for a principal

Treat order independence as a suite property worth enforcing in CI, because it is a precondition for parallel runs and hints at static state in production code.

## Why random order finds bugs By default PHPUnit 13 runs tests in the order it discovers them. That order is stable, so a hidden coupling - test B passes only because test A ran first and left something behind - never shows up. `executionOrder="random"` in `phpunit.xml`, or `--order-by random` (alias `--random-order`) on the command line, shuffles test classes and methods. A test that now fails intermittently is **order-dependent**: it relies on state it did not create, or it breaks state another test relies on. In a PHP test run all tests share **one process**. Anything that outlives a single test is a candidate: - **static properties**, including singletons, service locators, in-memory caches and registries; - **superglobals and environment**: `$_SERVER`, `$_ENV`, `putenv()`; - **process-wide settings**: `date_default_timezone_set()`, `setlocale()`, `ini_set()`, error handlers; - **external state**: files in a temp directory, rows in a shared test database, a fake clock left advanced. ## Step 1: reproduce deterministically Every randomized run prints the seed in its header: ``` Random Seed: 1758723977 ``` If you did not supply one, PHPUnit uses the current time. Replay the exact order with: ```bash vendor/bin/phpunit --order-by random --random-order-seed 1758723977 ``` Passing `--random-order-seed` without random order does nothing useful, and PHPUnit says so with a PHPUnit warning, which fails the run by default. Record the seed in CI logs so any red build can be replayed locally. ## Step 2: find the polluter 1. Run the replay with `--debug`, which replaces progress output with the event stream, including the order in which tests are prepared and finished. 2. The culprit is among the tests that ran **before** the failing one. Bisect: run those tests together with the failing test, halving the set until one earlier test is left. Narrowing the set of tests can change the shuffle even with the same seed, so check that each reduced run still fails rather than trusting the seed alone. 3. As a diagnostic, enable `backupStaticProperties="true"` (and `backupGlobals="true"` if superglobals are involved) with `beStrictAboutChangesToGlobalState="true"`. PHPUnit then snapshots state around each test and reports the test that changed it as risky, with a diff of what changed. The backups are too slow and too magical to keep on permanently in most suites, but they point straight at the leak. ## Step 3: fix isolation, not the order | Leak | Fix | |---|---| | Static cache or singleton | Reset it in `tearDown()`, or inject the dependency instead of reaching for static state | | `$_SERVER` / env changes | Save and restore in `setUp()`/`tearDown()` | | Time zone or locale | Set explicitly in each test that depends on it, restore afterwards | | Shared database rows | Wrap each test in a transaction, or create unique data per test | Pinning the suite back to default order hides the defect; the next refactor that adds a test in the wrong place brings it back. ## Why the seed matters in CI A randomized CI job without a recorded seed produces failures nobody can replay: the next run uses a different order, passes, and the report is closed as a flake. Keep the header line in the job log, and when a randomized run fails, copy the seed into the bug report together with the failing test's name. Some teams also pin the seed per pipeline run so that a retry of the same job uses the same order; that is fine as long as the seed still changes between pipelines. ## Interaction with other ordering features - **Dependencies:** `resolveDependencies` defaults to true, so a test with `#[Depends]` still runs after its producer in a shuffled run. - **Defects first:** `executionOrder="defects"` runs tests that failed last time first, using the recorded test run history in the cache directory. It is often combined as `defects,random`. - **Reverse:** `--order-by reverse` is a cheap, deterministic way to shake out simple A-before-B couplings. ## Where this sits in CI For a legacy project, turn random order on once the suite is green in default order, keep the seed in the job log, and treat an order-dependent failure as a real bug with an owner. It is a SENIOR topic because the fix usually changes production code: static state that makes tests order-dependent also makes long-running workers and queue consumers behave differently from a fresh request.

  • With PHPUnit 13, why not just run the suite in default order to keep CI green?
    Default order hides the coupling; it does not remove it. The dependency on leftover state is still there and resurfaces when a test is added, renamed or moved, or when tests are split across parallel workers. Static state that couples tests often causes the same bug in long-running workers, so fixing isolation is the real remedy.
  • In PHPUnit 13, does random order break tests linked with #[Depends]?
    No. `resolveDependencies` defaults to true, so PHPUnit keeps a producer before its dependent tests even when the rest of the order is shuffled. Tests coupled only through leftover state, without `#[Depends]`, get no such protection - which is exactly what random order is meant to expose.

saying these in an interview costs you the question

  • Fixes flakiness by switching back to the default order
  • Thinks each PHPUnit test runs in a fresh PHP process by default
  • Does not record the random seed, so failures cannot be replayed
  • Expects the global-state check to work without enabling backups
  • Blames PHPUnit's shuffling rather than state shared between tests