The Problem: Flaky Tests That Drive You Crazy
Picture this: You're building a multi-tenant Laravel CRM with custom session tracking. You have a UserSession model that stores active company context, last activity, and device info. You write tests. They pass. You run them again. They fail. You run them a third time. They pass.
Welcome to flaky test hell π₯
Understanding the Root Cause
In our application, we have middleware that checks for active user sessions:
// CheckSessionExists middleware
public function handle($request, Closure $next)
{
if (Auth::check()) {
$sessionExists = UserSession::where('user_id', Auth::id())
->where('session_id', session()->getId())
->exists();
if (!$sessionExists) {
Auth::logout();
return redirect()->route('login');
}
}
return $next($request);
}
Simple, right? But when testing features protected by this middleware, our tests would randomly fail because the UserSession record couldn't be found.
First Attempt: The Naive Approach
function actingAsWithSession($user)
{
$sessionId = Str::random(40);
session()->setId($sessionId);
session()->start();
test()->actingAs($user);
DB::table('user_sessions')->insert([
'user_id' => $user->id,
'session_id' => $sessionId,
'active_company_id' => null,
'ip_address' => '127.0.0.1',
'last_activity' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
return $sessionId;
}
Why This Failed
it('can access protected route', function () {
$user = User::factory()->create();
actingAsWithSession($user); // Creates session with ID "abc123"
$response = $this->get('/dashboard'); // Laravel creates NEW session!
$response->assertOk(); // FAILS - middleware can't find UserSession
});
The Issue: Each HTTP request ($this->get(), $this->post(), etc.) in Pest/PHPUnit creates a fresh application instance. Without proper session persistence, Laravel generates a new session ID, making our UserSession record orphaned.
Second Attempt: Static Session ID
function actingAsWithSession($user)
{
static $staticSessionId = null;
if ($staticSessionId === null) {
$staticSessionId = Str::random(40);
}
session()->setId($staticSessionId);
session()->start();
test()->actingAs($user);
session()->save(); // Key addition!
UserSession::updateOrCreate(
['session_id' => $staticSessionId],
[
'user_id' => $user->id,
'active_company_id' => null,
'ip_address' => '127.0.0.1',
'last_activity' => now(),
]
);
return $staticSessionId;
}
The Breakthrough (and New Problem)
Adding session()->save() was crucial - it forces Laravel to persist session data to the configured driver (file/redis/database).
But now we hit a different wall:
it('admin and user can coexist', function () {
$admin = User::factory()->admin()->create();
$user = User::factory()->create();
actingAsWithSession($admin); // Sets static session ID "xyz789"
actingAsWithSession($user); // REUSES SAME "xyz789"!
// Both users now share the same session - chaos!
});
Problem: The static variable is shared across the entire test suite. Multiple users = session collision.
Final Solution: User-Scoped Session Management
function actingAsWithSession($user)
{
// Key insight: Index sessions by user ID
static $sessionIds = [];
if (!isset($sessionIds[$user->id])) {
$sessionIds[$user->id] = Str::random(40);
}
$sessionId = $sessionIds[$user->id];
session()->setId($sessionId);
session()->start();
test()->actingAs($user);
// CRITICAL: Add data to ensure session is written
session()->put('auth_check', true);
session()->save();
UserSession::updateOrCreate(
['session_id' => $sessionId],
[
'user_id' => $user->id,
'ip_address' => '127.0.0.1',
'last_activity' => now(),
]
);
return $sessionId;
}
Why This Works
-
User Isolation: Each user gets their own session ID stored in
$sessionIds[$user->id] -
Session Persistence: Calling
session()->save()writes to the driver -
Data Guarantee: Adding a marker (
auth_check) ensures non-empty session data gets saved -
Eloquent Integration: Using
updateOrCreate()instead of raw query handles updates gracefully
Real-World Usage
it('redirects blocked users from dashboard', function () {
$user = User::factory()->blocked()->create();
actingAsWithSession($user);
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('user_blocked'));
});
it('allows multiple user contexts in same test', function () {
$admin = User::factory()->admin()->create();
$user = User::factory()->create();
// Each gets their own session
actingAsWithSession($admin);
$adminSession = session()->getId();
actingAsWithSession($user);
$userSession = session()->getId();
expect($adminSession)->not->toBe($userSession);
});
Critical Configuration: phpunit.xml
Don't forget to set the session driver correctly:
<php>
<env name="SESSION_DRIVER" value="file"/>
<!-- NOT "array" - file/redis/database ensure persistence -->
</php>
Using array driver in tests will cause sessions to not persist between requests!
Results
- β 100% test pass rate (previously ~60-70% due to flakiness)
- β Stable CI/CD pipeline (no more random failures)
- β Multi-user test scenarios work reliably
- β Team confidence in test suite restored
Key Takeaways
- Understand Laravel's test lifecycle: Each HTTP request creates a new app instance
-
Session persistence matters: Always
session()->save()when testing session-dependent features - Scope your static data: Use arrays indexed by entity ID, not single static values
- Choose the right driver: File/redis/database drivers persist; array driver doesn't
- Test determinism: If a test passes sometimes and fails others, you have a state management issue
Bonus: Testing Custom Middleware
With stable sessions, testing our session-checking middleware became trivial:
it('logs out users with invalid sessions', function () {
$user = User::factory()->create();
test()->actingAs($user); // NO UserSession record
$response = $this->get('/dashboard');
$response->assertRedirect(route('login'));
expect(Auth::check())->toBeFalse();
});
it('allows users with valid sessions', function () {
$user = User::factory()->create();
actingAsWithSession($user); // CREATES UserSession
$response = $this->get('/dashboard');
$response->assertOk();
expect(Auth::check())->toBeTrue();
});
Resources
Have you encountered similar testing challenges? Share your war stories in the comments! π
This article is based on real debugging experience in a production Laravel 12 + Vue 3 + Inertia.js CRM system. The complete test suite is available in our open-source project.
Top comments (0)