perkun.eu Services Portfolio Blog About Contact PL
← Blog

10/14/2025

How to test middleware in Laravel — practical patterns with Pest

TL;DR: Middleware is tested via full HTTP requests in Pest, not by unit testing the class itself. $this->get('/protected')->assertRedirect('/login'). You test behavior, not implementation.

Middleware is the layer that sits between an HTTP request and controller logic. Testing it “from the inside” — instantiating the class, mocking Request and Response — is the road to brittle tests that check implementation instead of behavior. The correct approach is testing through full HTTP requests.

Why not unit test middleware

A middleware unit test would look roughly like this:

$middleware = new RequireRole();
$request = Request::create('/admin', 'GET');
$next = fn($req) => new Response('ok');
$response = $middleware->handle($request, $next, 'admin');

Problems: manually creating a Request doesn’t go through Laravel routing, container bindings don’t work, session and auth aren’t initialized. You’re testing a piece of code in isolation from the framework, which is designed to work inside the framework. Test results may be green while the middleware is broken in production.

Pest setup

Configuration in pest.php:

<?php

uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

uses(Tests\TestCase::class)
    ->in('Unit');

In TestCase.php you have access to $this->actingAs($user) — a helper that sets an authenticated user in the test session. Also useful is $this->withoutMiddleware(SomeMiddleware::class) when you want to test a controller in isolation from a specific middleware.

Pattern for auth middleware

Three tests that cover the full spectrum of behavior:

<?php

use App\Models\User;

describe('auth middleware', function () {

    it('redirects guests to login', function () {
        $this->get('/dashboard')
            ->assertRedirect('/login');
    });

    it('allows authenticated users', function () {
        $user = User::factory()->create();
        
        $this->actingAs($user)
            ->get('/dashboard')
            ->assertOk();
    });

    it('returns 403 for users without required role', function () {
        $user = User::factory()->create(['role' => 'viewer']);
        
        $this->actingAs($user)
            ->get('/admin')
            ->assertForbidden();
    });

});

Each test checks one behavioral scenario. Test names are sentences describing expected behavior — it('redirects guests to login') — not implementation. When a test fails, you immediately know what broke.

Testing side effects

Middleware that does more than access control — e.g. logs every request to the database:

// RequestLoggerMiddleware logs path and user_id to the 'request_logs' table

it('logs the request to database', function () {
    $user = User::factory()->create();
    
    $this->actingAs($user)
        ->get('/protected-route');
    
    $this->assertDatabaseHas('request_logs', [
        'path' => '/protected-route',
        'user_id' => $user->id,
    ]);
});

it('does not log unauthenticated requests', function () {
    $this->get('/protected-route');
    
    $this->assertDatabaseCount('request_logs', 0);
});

assertDatabaseHas and assertDatabaseCount with the RefreshDatabase trait — the database is reset between tests, so assertions are isolated.

Faking external services

Middleware that calls an external API — e.g. verifies a Solana Pay transaction:

use App\Services\SolanaPayService;

it('allows access with valid solana payment', function () {
    $this->mock(SolanaPayService::class)
        ->shouldReceive('verify')
        ->once()
        ->with('valid-tx-signature')
        ->andReturn(true);
    
    $this->withHeaders(['X-Solana-Tx' => 'valid-tx-signature'])
        ->get('/subscriber-only')
        ->assertOk();
});

it('blocks access with invalid payment', function () {
    $this->mock(SolanaPayService::class)
        ->shouldReceive('verify')
        ->once()
        ->andReturn(false);
    
    $this->withHeaders(['X-Solana-Tx' => 'invalid-tx'])
        ->get('/subscriber-only')
        ->assertForbidden();
});

$this->mock() registers a mock in the Laravel container — the middleware receives the mock through dependency injection instead of the real service. shouldReceive('verify')->once() guarantees that the middleware calls the service exactly once — doesn’t call it multiple times, doesn’t skip it.

Summary

Testing middleware through full HTTP requests is the only approach that gives confidence that middleware works correctly in the framework’s context. The pattern is simple: one test per scenario (guest, authenticated, unauthorized role), assertDatabaseHas for side effects, $this->mock() for external services. Three tests per middleware is the minimum that provides meaningful coverage — writing them takes 15 minutes and saves hours of production debugging.