Pattern 1
Static property leakage between tests
Symptom. A test that mutates a `static` property on a service class passes alone and breaks an unrelated test class with a value the second test never set.
Root cause. PHPUnit creates a fresh instance of the test class per method, but PHP static properties live for the life of the worker. A test that calls FeatureFlag::set('NEW_BILLING', true) leaves the flag set for every later test in the run. The autoloader's cache, registry singletons, and any class with a static collection are vulnerable to the same leak.
class FeatureFlag {
public static array $flags = [];
public static function set(string $k, bool $v): void { self::$flags[$k] = $v; }
}
class FeatureFlagTest extends TestCase {
public function testNewBillingPath(): void {
FeatureFlag::set('NEW_BILLING', true);
$this->assertTrue(FeatureFlag::$flags['NEW_BILLING']);
}
}
class LegacyBillingTest extends TestCase {
public function testCharge(): void {
// expects FeatureFlag::$flags empty; actual: ['NEW_BILLING' => true] leaked
$this->assertSame(99, Pricing::for('pro'));
}
}
Fix. Reset static state in tearDown(), or use the @backupStaticAttributes annotation (slow). The cleanest fix is to avoid static mutable state in tested classes; pass configuration through a service container instead.
class FeatureFlagTest extends TestCase {
protected function tearDown(): void {
FeatureFlag::$flags = [];
parent::tearDown();
}
public function testNewBillingPath(): void {
FeatureFlag::set('NEW_BILLING', true);
$this->assertTrue(FeatureFlag::$flags['NEW_BILLING']);
}
}
With Mergify. Test Insights catches the cross-test signature: a test only fails after a specific other test has run, with assertions about a value the failing test never set. The dashboard surfaces the predecessor so the leaking static is the obvious lead.
Pattern 2
RefreshDatabase against parallel paratest workers
Symptom. A Laravel test suite that runs green sequentially fails under `paratest` with `SQLSTATE: database is locked` or assertions about rows that another worker created.
Root cause. RefreshDatabase wraps each test in a transaction. With one worker that works fine. paratest spawns N workers, all hitting the same database file or schema. SQLite locks immediately; Postgres/MySQL race on autoincrement IDs. Laravel ships --recreate-databases to fix this but you have to enable it.
// phpunit.xml
<phpunit>
<php>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value="database/testing.sqlite"/>
</php>
</phpunit>
# CI script
vendor/bin/paratest -p 4
# Worker 1 transaction holds, Worker 2 hits SQLITE_BUSY, suite fails.
Fix. Run paratest --recreate-databases so each worker gets a per-process database (or schema, depending on the driver). For Postgres/MySQL, set DB_DATABASE_TEST with a placeholder Laravel substitutes per worker.
# CI script
vendor/bin/paratest -p 4 --recreate-databases --runner=WrapperRunner
# .env.testing
DB_CONNECTION=pgsql
DB_DATABASE=test_${TEST_TOKEN}
With Mergify. Test Insights tags failures that only appear under parallel runs as parallelism-sensitive. The dashboard surfaces the parallel-only signature so the worker-database collision is the obvious root cause.
Pattern 3
dataProvider returning shared mutable state
Symptom. A data-driven test passes for some inputs and fails for others, and re-running with the failing input alone passes.
Root cause. @dataProvider returns an iterable of arguments that PHPUnit calls the test with once per row. If the rows hold mutable objects, every iteration mutates the same instance. Row 0 changes a shared User, row 1 sees the post-row-0 state.
class PricingTest extends TestCase {
private static User $sharedUser;
public static function setUpBeforeClass(): void {
self::$sharedUser = new User(name: 'Rémy');
}
public static function plans(): array {
return [['free', self::$sharedUser], ['pro', self::$sharedUser]];
}
/** @dataProvider plans */
public function testRenamesUser(string $plan, User $u): void {
$u->setName($u->getName() . '-' . $plan);
$this->assertStringEndsWith($plan, $u->getName());
// row 1: 'Rémy-free' renamed to 'Rémy-free-pro'; assertion still passes
// row 2 might run first under parallel: name is wrong by the time row 1 reads
}
}
Fix. Build fresh instances inside the provider. PHPUnit calls the provider once and iterates the result, so building inside the iteration body keeps each row independent.
public static function plans(): array {
return [
['free', new User(name: 'Rémy')],
['pro', new User(name: 'Rémy')],
];
}
With Mergify. Test Insights groups failures by test method and parameter index. When iteration N of a data-provider test fails consistently and N-1 passed, the dashboard surfaces the iteration-order signature so the shared-state mistake is easy to find.
Pattern 4
Mockery expectations leaking across tests
Symptom. A test sets a Mockery expectation, the assertion passes, and a sibling test fails on a method that was never declared in its own scope.
Root cause. Mockery stores expectations on a global container. Mockery::close() in tearDown verifies and clears them, but tests that skip tearDown (an exception in setUp, an early return) leave expectations behind. The next test sees a mock with stubbed behavior it never declared.
class BillingTest extends TestCase {
public function testChargesUser(): void {
$stripe = Mockery::mock(StripeClient::class);
$stripe->shouldReceive('charge')->andReturn(true);
// exits without Mockery::close(); expectation persists
$this->assertTrue(Billing::for($stripe)->chargeUser(42));
}
public function testFallback(): void {
$client = new StripeClient();
// sees the global Mockery container with the leftover expectation
$this->assertTrue(Billing::for($client)->fallbackFor(42));
}
}
Fix. Use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration (a trait) so Mockery::close() runs automatically after every test, even on failure. For Laravel, the trait is included in the base TestCase.
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
class BillingTest extends TestCase {
use MockeryPHPUnitIntegration; // close() fires in tearDown automatically
public function testChargesUser(): void {
$stripe = Mockery::mock(StripeClient::class);
$stripe->shouldReceive('charge')->andReturn(true);
$this->assertTrue(Billing::for($stripe)->chargeUser(42));
}
}
With Mergify. Test Insights catches the cross-test signature: a test only fails when run after a specific other test, and only when the assertion involves a Mockery-managed dependency. The dashboard tags the dependency so you know it's mock leakage, not a real regression.
Pattern 5
Carbon::now() without setTestNow
Symptom. A test that calls `Carbon::setTestNow('2026-01-01')` passes, and the next test that touches `Carbon::now()` fails with a date months in the past or future.
Root cause. Carbon::setTestNow mutates a static on the Carbon class. Without a paired Carbon::setTestNow(null) in tearDown, the frozen time persists for every test that touches Carbon::now() on the same worker. Tests that were green for months go red after the first test that froze time without restoring it.
class InvitationTest extends TestCase {
public function testExpiresAfterSevenDays(): void {
Carbon::setTestNow('2026-01-01');
$invite = Invitation::create();
Carbon::setTestNow(Carbon::now()->addDays(8));
$this->assertTrue($invite->isExpired());
// missing Carbon::setTestNow(null);
}
}
class SessionTest extends TestCase {
public function testTokenExpiresInOneHour(): void {
$token = SessionToken::for($this->user);
// Carbon::now() is still January 9 2026; assertion fails
$this->assertEqualsWithDelta(
Carbon::now()->addHour()->timestamp,
$token->expiresAt->timestamp,
60
);
}
}
Fix. Reset Carbon's test clock in tearDown() on a base test case so every test starts with the real clock. For one-off freezes, prefer the closure form that auto-restores.
abstract class TestCase extends BaseTestCase {
protected function tearDown(): void {
Carbon::setTestNow(); // reset to real clock
parent::tearDown();
}
}
// or use the closure form:
Carbon::setTestNow('2026-01-01', function () {
$invite = Invitation::create();
Carbon::setTestNow(Carbon::now()->addDays(8));
$this->assertTrue($invite->isExpired());
});
With Mergify. Test Insights shows the cross-test time signature: a test only fails after a known time-mutating test, and only when its assertions touch the clock. The dashboard surfaces the ordering so the missed setTestNow(null) is easy to locate.
Pattern 6
Guzzle MockHandler queues that outlive their test
Symptom. A test that pushes responses onto a Guzzle MockHandler passes, and a later test fails with a response from the previous test's queue.
Root cause. Guzzle's MockHandler is a queue. A test that calls $handler->append(new Response(200, [], '{}')) three times and only consumes two leaves one queued. If the handler is shared via a service container, the next test that resolves the same client pulls the leftover response instead of making a real call.
class HttpClientTest extends TestCase {
public function testFetchesUser(): void {
$handler = new MockHandler([new Response(200, [], json_encode(['name' => 'Rémy']))]);
$client = new Client(['handler' => HandlerStack::create($handler)]);
// app()->instance(Client::class, $client); // bound for the whole test run
$this->assertSame('Rémy', User::fetch()->name);
}
public function testHandles404(): void {
// expected: a fresh client; actual: same MockHandler with stale queue
$this->expectException(NotFoundHttpException::class);
User::fetch();
}
}
Fix. Build a fresh handler per test inside setUp and bind it explicitly. Avoid sharing the handler instance across tests through the service container.
class HttpClientTest extends TestCase {
private MockHandler $handler;
protected function setUp(): void {
parent::setUp();
$this->handler = new MockHandler();
app()->instance(Client::class, new Client([
'handler' => HandlerStack::create($this->handler),
]));
}
public function testFetchesUser(): void {
$this->handler->append(new Response(200, [], json_encode(['name' => 'Rémy'])));
$this->assertSame('Rémy', User::fetch()->name);
}
}
With Mergify. Test Insights groups failures whose only signature is unexpected HTTP responses or network exceptions into a per-suite bucket. The dashboard surfaces the test that first poisoned the shared handler so the fix lands at the source.
Pattern 7
processIsolation traps for global state
Symptom. A test class with `@runInSeparateProcess` passes locally and fails in CI with a `serialize()` error or a missing service-container binding.
Root cause. @runInSeparateProcess forks a fresh PHP process per test for full isolation. PHPUnit serializes test state across the process boundary, but anonymous classes, closures with non-serializable bindings, and database connections cannot cross. A test that captures any of those in setUp fails on the serialize step in subtle ways.
/** @runInSeparateProcess */
class ConfigTest extends TestCase {
private \Closure $factory;
protected function setUp(): void {
// captures the test instance (\$this) which holds a PDO connection
$this->factory = fn (string $k) => Config::get($k);
}
public function testReadsConfig(): void {
$this->assertSame('value', ($this->factory)('key'));
// SerializationException: closure cannot be serialized for the child process
}
}
Fix. Use @runInSeparateProcess only when you need true global-state isolation (testing autoloader behavior, INI changes). For cross-process tests, build dependencies in the test method body, not in setUp, and avoid storing closures or database handles on $this.
/** @runInSeparateProcess */
class ConfigTest extends TestCase {
public function testReadsConfig(): void {
$factory = fn (string $k) => Config::get($k);
$this->assertSame('value', $factory('key'));
}
}
With Mergify. Test Insights groups serialize-related failures distinctly from logic failures. The dashboard surfaces tests that fail with `SerializationException` so the @runInSeparateProcess scope decision is the obvious place to look.
Pattern 8
PHPUnit retry attribute hiding real bugs
Symptom. Your CI is green. A user reports a bug your tests should have caught, and the test report shows the failing test passed on attempt 3.
Root cause. Plugins like phpunit-retry add a @retryAttempts(3) annotation that reruns failing tests up to N times. A real race that loses on attempt 1 and wins on attempt 2 gets reported as PASSED. The bug is still there. The pipeline has decided not to look at it.
use jamesheinrich\phpunit_retry\RetryTrait;
class CheckoutTest extends TestCase {
use RetryTrait;
/** @retryAttempts 3 */
public function testChargesCard(): void {
// intermittent timing bug; passes 2 of 3 times
}
}
Fix. Do not retry at the framework level. When a test is genuinely flaky, fix it. When the fix takes longer than a session, quarantine it instead. That keeps the signal visible without blocking the merge queue.
With Mergify. Test Insights reruns at the CI level with attempt-level result tracking. You see that a test passed on attempt 2 of 3, which is exactly the information `@retryAttempts` throws away. Quarantine kicks in once the pattern is clear.