Pattern 1
Higher-order chains that share state across iterations
Symptom. A higher-order Pest test that expects multiple values on the same subject passes locally and fails in CI when the subject's state changed between expectations.
Root cause. Higher-order tests chain method calls on the test's expected subject. Each link in the chain operates on the same value, so a chain that includes a method call with side effects (a fluent builder, a state machine transition) carries those side effects into the next assertion. Locally the chain runs fast enough that intermediate state matches; on CI a slower scheduler exposes the order dependence.
it('renames and saves the user')
->expect(fn () => User::create(['name' => 'Rémy']))
->name->toBe('Rémy')
->setName('Rémy Updated') // mutates the expected value
->name->toBe('Rémy Updated')
->save() // calls a slow DB write
->name->toBe('Rémy Updated'); // sometimes sees a stale cached value
Fix. Use higher-order chains for read-only assertions on a single value. When the test mutates the subject, switch to an explicit closure body so each step's side effects are visible and the order is unambiguous.
it('renames and saves the user', function () {
$user = User::create(['name' => 'Rémy']);
expect($user->name)->toBe('Rémy');
$user->setName('Rémy Updated');
expect($user->name)->toBe('Rémy Updated');
$user->save();
expect($user->fresh()->name)->toBe('Rémy Updated');
});
With Mergify. Test Insights groups higher-order test failures by the assertion position in the chain. When the same chain consistently fails at step 3 of 5, the dashboard surfaces the cross-step state dependency so the side-effect-free rewrite is the obvious fix.
Pattern 2
beforeEach mutating global state without afterEach cleanup
Symptom. A test inside a `describe` block changes a Laravel facade fake or a static class property, the next test in a sibling block fails on an assertion about a value the failing test never set.
Root cause. Pest's beforeEach always fires before every test in scope, but it does not auto-reset what it changed. A hook inside a describe that mutates a static property, registers a permanent event listener, or fakes a facade without a paired afterEach leaks that change into every later test in the file. The file-level beforeEach does not undo it.
// tests/Feature/InvoiceTest.php
describe('with frozen audit log', function () {
beforeEach(function () {
AuditLog::$disabled = true; // static property mutation
});
test('processes order without audit', function () {
expect(processOrder())->toBeTrue();
});
});
// later in the same file, no longer inside the describe
test('writes an audit entry for refunds', function () {
refund();
// expects AuditLog::$disabled === false (default)
// actual: the previous describe set it true and never reset it
expect(AuditLog::entries())->toHaveCount(1);
});
Fix. Pair every beforeEach that mutates global state with an afterEach in the same scope so the change is undone before the next test. For Laravel facades, prefer the test traits Laravel ships (RefreshDatabase, WithFaker) that handle teardown for you.
describe('with frozen audit log', function () {
beforeEach(fn () => AuditLog::$disabled = true);
afterEach(fn () => AuditLog::$disabled = false);
test('processes order without audit', function () {
expect(processOrder())->toBeTrue();
});
});
With Mergify. Test Insights groups failures by the describe block that ran before them. When several seemingly unrelated tests start failing only after a specific describe runs, the dashboard surfaces the upstream culprit so the missing afterEach is the obvious lead.
Pattern 3
Parallel paratest workers fighting over the Laravel database
Symptom. A Pest suite that runs green sequentially fails under `pest --parallel` with assertions about rows another worker created or `SQLSTATE: database is locked`.
Root cause. Pest's parallel runner is paratest under the hood. RefreshDatabase wraps each test in a transaction, but every parallel worker hits the same database file or schema. SQLite locks immediately; Postgres/MySQL race on autoincrement IDs. Pest does not auto-recreate per-worker databases the way php artisan test --parallel does unless the runner is configured to.
# CI script
vendor/bin/pest --parallel --processes=4
# Worker 1 starts a transaction, Worker 2 hits SQLITE_BUSY, suite fails.
# .env.testing
DB_CONNECTION=sqlite
DB_DATABASE=database/testing.sqlite
Fix. Use Laravel's php artisan test --parallel wrapper around Pest, which handles per-worker databases automatically. For raw paratest invocations, set the database name to a per-token placeholder.
# CI script
php artisan test --parallel --processes=4
# Or with raw paratest:
vendor/bin/pest --parallel --recreate-databases
# .env.testing for Postgres
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 4
dataset() closures capturing mutable outer state
Symptom. A dataset-driven test passes for some inputs and fails for others, and re-running with the failing input alone passes.
Root cause. Pest datasets can be defined as closures that build values per iteration. A closure that captures a mutable object from the surrounding scope returns the same instance on every call, so each iteration sees the previous one's mutations. use ($shared) in a dataset closure does not give the iteration its own copy.
$shared = User::factory()->make(['name' => 'Rémy']);
dataset('plans', function () use ($shared) {
// closure captures $shared by reference; every yield returns the SAME instance
yield 'free' => ['free', $shared];
yield 'pro' => ['pro', $shared];
});
it('renames the user', function (string $plan, User $u) {
$u->setName($u->getName() . '-' . $plan);
expect($u->name)->toEndWith($plan);
// row 1 mutates $shared; row 2 sees 'Rémy-free' and renames to 'Rémy-free-pro'
})->with('plans');
Fix. Build fresh values inside the dataset closure on every yield so each iteration gets its own. For expensive setup, factor it into a factory function the dataset calls per row.
dataset('plans', function () {
yield 'free' => ['free', User::factory()->make(['name' => 'Rémy'])];
yield 'pro' => ['pro', User::factory()->make(['name' => 'Rémy'])];
});
With Mergify. Test Insights groups failures by test name and dataset row. When iteration N of a dataset-driven test fails consistently and N-1 passed, the dashboard surfaces the iteration-order signature so the shared-closure mistake is easy to find.
Pattern 5
Snapshot-driven tests racing in parallel
Symptom. A test using `toMatchSnapshot()` writes a snapshot file in CI and the next run fails with a diff that did not exist between commits.
Root cause. Snapshot files in spatie/pest-plugin-snapshots live on disk under tests/__snapshots__. Under --parallel, two workers can write to the same snapshot file simultaneously when two tests share the same descriptor (a parameterised test, two tests with the same name in different describes). The result is one snapshot file with content from whichever write landed last.
it('renders invoice in USD', function () {
expect(format(1099, 'USD'))->toMatchSnapshot();
});
it('renders invoice in EUR', function () {
expect(format(1099, 'EUR'))->toMatchSnapshot();
});
# Both tests can run on different workers and write to:
# tests/__snapshots__/InvoiceTest__renders_invoice__1.snap
# (the snapshot key collides when the test names share a prefix)
Fix. Keep snapshot tests in the same file and either run them serially (group them under a single describe with explicit naming) or split snapshot tests into a separate suite that does not run with --parallel.
// pest.php
uses(MatchesSnapshots::class)->in('Snapshots');
// CI:
vendor/bin/pest --testsuite=Unit --parallel
vendor/bin/pest --testsuite=Snapshots // sequential
With Mergify. Test Insights detects the snapshot-churn pattern: the same test file produces a different .snap diff on consecutive runs of the same SHA. The dashboard surfaces the file as snapshot-unstable so the parallel-write race is the obvious lead.
Pattern 6
Carbon test-time leakage between specs
Symptom. A test that calls `Carbon::setTestNow('2026-01-01')` passes, and a later spec that touches `Carbon::now()` asserts on a date months in the past.
Root cause. Carbon's setTestNow mutates a static. Pest does not auto-reset it between tests. A spec that froze the clock without resetting in afterEach leaves the clock frozen for every test that runs after on the same worker. The leak is invisible until a later test reads Carbon::now() and fails.
it('expires the invitation', function () {
Carbon::setTestNow('2026-01-01');
$invite = Invitation::create();
Carbon::setTestNow('2026-01-09');
expect($invite->isExpired())->toBeTrue();
// missing Carbon::setTestNow();
});
it('mints a session token valid for an hour', function () {
$token = SessionToken::for($this->user);
// Carbon::now() is still 2026-01-09; assertion fails
expect($token->expiresAt->timestamp)
->toBeWithin(60, Carbon::now()->addHour()->timestamp);
});
Fix. Reset Carbon's test clock in a global afterEach in tests/Pest.php. Or use the closure form of Carbon::withTestNow so the clock auto-restores at the end of the block.
// tests/Pest.php
afterEach(function () {
Carbon::setTestNow(); // reset to real clock
});
// in a test
it('expires the invitation', function () {
Carbon::withTestNow('2026-01-01', function () {
$invite = Invitation::create();
Carbon::setTestNow('2026-01-09');
expect($invite->isExpired())->toBeTrue();
});
});
With Mergify. Test Insights catches the cross-test 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 Carbon::setTestNow() is easy to locate.
Pattern 7
Browser tests via Pest Plugin Browser racing the page
Symptom. A `pestphp/pest-plugin-browser` (Playwright-driven) test passes locally and fails in CI with `element not found` on a button the screenshot clearly shows.
Root cause. pest-plugin-browser wraps Playwright and inherits its auto-wait semantics. A test that sleeps for a hardcoded duration before clicking races the page exactly the way bare Playwright does. Locally the wait covers the render; on the slower CI runner the click fires before the element is interactable.
it('opens the modal', function () {
visit('/dashboard');
click('button.open-modal');
sleep(1); // hope the modal is open
type('input[name=email]', 'user@example.com');
// CI: input not found, the modal animation is still running
});
Fix. Wait for the actual signal: an assertion that the modal is visible. The Pest browser plugin's assertVisible retries with the configured timeout, so the test is fast when fast and patient when slow.
it('opens the modal', function () {
visit('/dashboard');
click('button.open-modal');
assertVisible('[role=dialog]');
type('[role=dialog] input[name=email]', 'user@example.com');
});
With Mergify. Test Insights links browser-test failures to their CI runner type. When a Pest browser test only fails on the slower runner pool and never on the laptop pool, the dashboard surfaces the resource sensitivity so the hardcoded sleep is the obvious place to look.
Pattern 8
Pest's retry expression hiding real bugs
Symptom. Your Pest suite is green. A user reports a bug that should have been caught by the test that ran three times yesterday before passing.
Root cause. Pest supports retries via --retry on the CLI or per-test ->retry(). 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.
// CI script (please don't)
vendor/bin/pest --retry
// or per test (please don't)
it('charges the card', function () {
// intermittent timing bug
})->retry(3);
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 `--retry` and `->retry()` throw away. Quarantine kicks in once the pattern is clear.