Skip to content

Process-lifetime state

Runabout resets the database between trails, but a PHPUnit process holds far more state than that, and much of it lives until the test ends. Journeys exercise it much harder than ordinary tests do, because one test makes hundreds of requests over many trails. In production each request starts in a fresh process, or at least a freshly reset application. In a test, a request inherits whatever earlier requests and trails left in memory.

The first sign is usually a confusing failure: a trail that fails only after others have run, or a shrunk trail that passes on replay.

What survives, and the fix

State Survives Typical symptom Fix
Rate-limiter counters in an in-memory cache store trails actors are answered with 429s after a few trails flush the store after each trail with resetExternal()
Cache entries keyed by ids trails trail 2 reads trail 1's cached rows, especially on SQLite, which hands the same ids out again after a rollback resetExternal(), as above
Static memo caches and singletons in app code trails stale lookups after a rollback reset them in resetExternal()
Laravel Context and scoped container instances requests a flag one request sets changes the behaviour of later ones isolateRequests()
The actor's user instance trails permissions another actor granted or revoked are ignored freshActors()
Mail, notification, queue and event fakes the test counts and assertions accumulate across trails fake inside a step, not in setUp()
Auth guards logged in from setUp() the test the default guard drifts, or the wrong user is logged in authenticate through actors
The clock moved with $ctx->travel() or travelTo() nothing none: Runabout unwinds it at the end of every trail Time travel

resetExternal() runs a cleanup after every trail, so it can flush an in-memory cache store and reset static caches:

$this->journey(SomeJourney::class)
    ->resetExternal(function (): void {
        Cache::store('array')->flush();
        Settings::flushMemo();
    })
    ->run();

isolateRequests() clears Context and scoped instances before every actor request, and reloads actors' users. It's off by default, so outcomes and trails don't change unless you ask.

Fakes

Mail::fake(), Notification::fake(), Queue::fake() and Event::fake() in setUp() install one fake for the whole test, and it records every trail's messages. assertSentCount(1) is then wrong from the second trail, and assertNothingSent() fails as soon as any trail sent anything.

Install the fake in a step that runs first, so each trail starts with a fresh one, or make the assertions trail-relative:

Step::make('set up')->act(function (Context $ctx): void {
    Mail::fake();
}),