The ugly mug of the author

By Kai Sassnowski

GithubTwitter

I’m writing a book!

If you learned something from my blog or enjoy my style of writing you should check out the book I’m writing. It’s called Developer’s Guide to Database Indexing and will be stock full of all the things you should know about database indexing as a developer.

Sign up for the mailing list and be the first to know when the first chapters go online!

Check it out

Testing HTTP Middleware in Laravel

Testing HTTP middleware in a Laravel app might look easy at first glance but can be surprisingly difficult to get right. There are many possible pitfalls. In this post, I want to show some of these pitfalls and what problems they can cause. Then, I will explain how I write tests for my custom middleware.

Introduction

We often have a group of routes that are supposed to share a certain behavior. They might require admin privileges or are only accessible for users with an active subscription. This is a good use case for a middleware. You can define the behavior once and then reuse it across multiple routes.

Writing tests for custom middlewares can be a bit tricky, however. Should I use a real route or try and test the middleware in a more isolated way? Don’t I still need some kind of request context?

First, let’s look at two approaches to testing middleware that didn’t work for me and why I would recommend against using them.

Approach 1: Unit testing

Looking at just the method signature of a middleware in Laravel, you might wonder what the big deal is.

class MyMiddleware
{
    public function handle(Request $request, Closure $next) {…}
}

A middleware only needs to provide a single handle method with two parameters. The first parameter is the current Request object. The second parameter is a Closure that hands the request off to the next middleware in the stack.

Isn’t this basically a function that we should be able to test in isolation? Create a Request object that represents the HTTP request we want to simulate and a dummy callback that we can inspect afterwards? Something like this:

/** @test */
public function shouldCallNextCallbackIfEverythingWorks(): void
{
    $called = false;
    $request = new Request(/* set up request here */);
    $next = function (Request $request) use (&$called) {
        $called = true;
    };

    (new MyMiddleware())->handle($request, $next);

    $this->assertTrue($called);
}

This would actually work but I hid the most difficult part behind this innocent‑looking comment.

$request = new Request(/* set up request here */);

Laravel’s Request class extends Symfony’s HttpFoundation\Request. See, the problem is that that class’ constructor looks like this:

public function __construct(
  array $query = [],
  array $request = [],
  array $attributes = [],
  array $cookies = [],
  array $files = [],
  array $server = [],
  $content = null
) {…}

Oh boy. That’s a lot of parameters. And every one of them is optional.

Setting up a specific HTTP request manually would be incredibly tedious. It would also be very easy to make a mistake. You would have to know what each of these parameters has to be set to for a specific request. This gets even worse when we want to do something like accessing the user that sent the request like this:

public function handle(Request $request, Closure $next)
{
    $user = $request->user();

    // do something with the user here
}

Not only would we have to set up all the HTTP specific things, but also stub out Laravel-specific behavior. The chance of messing something up in the set up is quite high. This could lead to us writing our tests against an incorrect assumption. All of our tests would be green, but the first time we actually run it for real it blows up.

Another problem with this approach is that configuring the Request object would take up the majority of the test case. The more a middleware depends on specific properties of incoming HTTP request, the more complicated the test setup becomes.

I have written at length about the properties I care about in my tests. This approach violates several of them:

  1. It makes the test significantly more difficult to understand. Before we can get to what is even being tested, we have to decipher the test setup just to know what HTTP request we’re dealing with.
  2. It detracts from the thing that is actually the star of this test: the middleware.
  3. It reduces my confidence in this test since there is a high chance I messed up the test setup somehow.

All in all, I would not recommend this approach to testing middleware. So what else could we try?

Approach 2: Using a real route

Since we created a middleware, that means we have at least a few routes that use it, right? So why not pick one of those routes and use it as a kind of “proxy” for testing our middleware. This way we could leverage Laravel’s built-in HTTP testing helpers and completely avoid having to muck around with the Request object.

Let’s pick a slightly more realistic example of a middleware. How about this:

class AdminMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        // Fancy PHP 8 nullsafe operator
        if (! $request->user()?->admin) {
            abort(403);
        }

        $next($request);
    }
}

This middleware only allows requests that were sent by an admin user. Otherwise, it returns a Forbidden response.

Let’s say we have a route that uses this middleware somewhere in our application.

Route::get('/do-thing', [ThingController::class, 'do'])
    ->middleware('admin');

// Lots of other routes

We could test our middleware by calling this route in our tests as a non-admin user. It should return a 403 status code in this case.

class MyMiddlewareTest extends TestCase
{
    /** @test */
    public function cannotAccessRouteIfUserIsNotAnAdmin(): void
    {
        $nonAdminUser = User::factory()->create();

        $this->actingAs($nonAdminUser)
             ->get('/do-thing')
             ->assertForbidden();
    }
}

Would this work? It sure would! And more importantly, this is the kind of test I want to write for my middleware. Laravel already makes testing at the HTTP level super easy, so there is no need to try and simulate this behavior ourselves. Doing things like $request->user() inside our middleware would also Just Work this way.

We did it! Right?

You know how in movies it’s always super obvious when they caught the wrong person because we’re only like halfway in? Well, we’re only about halfway in to this post. So... what’s wrong with this approach?

Let’s take another look at the test we just wrote. This time, I won’t include the class definition, however.

/** @test */
public function cannotAccessRouteIfUserIsNotAnAdmin(): void
{
    $nonAdminUser = User::factory()->create();

    $this->actingAs($nonAdminUser)
          ->get('/do-thing')
          ->assertForbidden();
}

Without any context, what would you say this test is actually testing? Because we both know that our intention was to test the AdminMiddleware. Is this what the test is communicating? This looks like a test for the /do-thing route to me. And apparently that route should only be accessible by admins. No mention of a middleware anywhere.

If this seems a bit too esoteric to be a real problem, consider this. The point of using a middleware is to reuse it for multiple routes. In this case, you’re left with two options if you still want to use a real route in your test:

Option 1. You could write the same kind of test for every route that uses the middleware. This would obviously lead to a lot of duplicate code all over your test suite. What if the middleware needs to change? Each of those duplicate tests would start failing at the same time. And as a rule of thumb, if you have a group of tests that always pass or fail at the same time, you’re probably missing something.

Option 2. So copy and pasting the same test everywhere doesn’t seem all that great. What if we instead pick one of these routes and use it as a “proof of concept” for the middleware? This way we would only have to write the tests once because it’s the same for all other routes anyways.

Sure! Which route do we pick? Why that one instead of this other one? Do we pick the same route for all tests, or do we switch it up from time to time? Again, think about what these tests communicate to someone unfamiliar with your codebase. By singling out a particular route with which to test the middleware, we have made this route “special” somehow. Is this the only route that only admins can access? If not, why is it the only one with a tests for this?

There is also the problem that we have to trust ourselves not to mess up and forget to add the other routes to the same middleware group.

Route::middleware('admin')->group(function () {
  Route::get('/admin/users', [Admin\UsersController::class, 'index']);
});

// Whoopsie...
Route::get('/admin/users/{user}', [Admin\UsersController::class, 'show']);

Not that that has ever happened to me before. Nope.

So this also sucks. But it feels like we’re really close. If only we could find a way to write a test like this without having to use a real route.

Well, guess what.

Writing a test like this without having to use a real route

I’ve shown you two different approaches and why I would recommend against using them. So finally, here is what I do in my projects. This might either seem super obvious to you or it’ll blow your mind. Or anything in between.

We want to test our middleware using Laravel’s built-in HTTP testing helpers, right? But to do so, we need a route and we don’t want to use a real application route because of reasons. So why not register a dummy route in our test and use that? Nothing is stopping us from doing this:

class AdminMiddlewareTest extends TestCase
{
    protected function setUp(): void
    {
        parent::__setUp(); // Don't forget this!

        Route::get('/dummy-test-route', function () {
            return 'nice';
        })->middleware(['web', 'admin']);
    }

    // Tests go here
}

Now we can actually write tests for the middleware without the ambiguity of using a real route. There is no confusion about the route we’re using since it doesn’t even exist outside of this file. Note that we’re applying both the web and admin middleware to the route. This is necessary in our case because in order for $request->user() to work, we need the StartSession middleware that’s part of the web group to run first.

Here’s the complete set of tests for our admin middleware.

class AdminMiddlewareTest extends TestCase
{
    protected function setUp(): void
    {
        parent::__setUp();

        Route::get('/dummy-test-route', function () {
            return 'nice';
        })->middleware(['web', 'admin']);
    }

    /** @test */
    public function canBeAccessedByAdminUser(): void
    {
        $admin = User::factory()->admin()->create();

        $this->actingAs($admin)
          ->get('/dummy-test-route')
          ->assertOk();
    }

    /** @test */
    public function cannotBeAccessedByNonAdminUser(): void
    {
        $nonAdmin = User::factory()->create();

        $this->actingAs($nonAdmin)
          ->get('/dummy-test-route')
          ->assertForbidden();
    }

    /** @test */
    public function cannotBeAccessedByGuests(): void
    {
        $this->get('/dummy-test-route')
          ->assertForbidden();
    }
}

Super dope, right?

Check that route is using middleware

All that’s left to do is test that our routes actually use the middleware. I think a lot of people would feel comfortable skipping this step because aren’t you really just testing configuration at that point? I, however, have learned not to trust myself.

Since we can now be confident that our middleware works, we don’t have to be concerned about its behavior when testing our routes. All we want to know is that the route actually uses it.

To do this, I use a small package by Jason McCreary called Laravel Test Assertions. It provides a handy assertRouteUsesMiddleware assertion which does exactly what we need.

class ManageUsersTest extends TestCase
{
    // Lots of tests here that test this feature.

    /** @test */
    public function onlyAdminCanCreateUsers(): void
    {
        $this->assertRouteUsesMiddleware(
            'admin.users.store',
            ['admin']
        );
    }
}

The assertRouteUsesMiddleware method takes the route name and a list of middleware it is supposed to use. By default, it checks if the route’s middleware contains this middleware, but there’s also an optional third parameter to change this to look for an exact match. We’re fine with the default behavior since we don’t really care about what other middleware this route is using. All we wan’t to know is that it also uses the admin middleware.

Because we wanted to avoid writing the same test for multiple routes, we can use a data provider to parameterize the route name.

class ManageUsersTest extends TestCase
{
    // Lots of tests here that test this feature.

    /**
     * @test
     * @dataProvider protectedRoutesProvider
     */
    public function onlyAdminCanAccessRoute(string $routeName): void
    {
        $this->assertRouteUsesMiddleware(
            $routeName,
            ['admin']
        );
    }

    public function protectedRoutesProvider(): Generator
    {
        yield from [
            'admin.users.index',
            'admin.users.create',
            'admin.users.store',
            'admin.users.show',
            'admin.users.edit',
            'admin.users.update',
            'admin.users.delete',
        ];
    }
}

This way we can define the test once and use it for many different routes.

There might be more routes than just admin.users.* which only admins should be able to access. The tests for these routes usually live in other files since I tend to group my tests by “features”. I want all tests about a feature to live in the same file, so we need a way to reuse the above test across multiple test classes.

Here’s one way we can do this:

trait AdminProtectedRoutesTest
{
    abstract public function protectedRoutesProvider(): Generator;

    /**
     * @test
     * @dataProvider protectedRoutesProvider
     */
    public function onlyAdminCanAccessRoute(string $routeName): void
    {
        $this->assertRouteUsesMiddleware(
            $routeName,
            ['admin']
        );
    }
}

We’ve pulled our test into a separate trait and marked the data provider as abstract. All we have to do now is to use the trait in our test file and implement the protectedRoutesProvider method.

class ManageUserTest extends TestCase
{
    use AdminProtectedRoutesTest;

    public function protectedRoutesProvider(): Generator
    {
        yield from [
            'admin.users.index',
            'admin.users.create',
            'admin.users.store',
            'admin.users.show',
            'admin.users.edit',
            'admin.users.update',
            'admin.users.delete',
        ];
    }
}

Now we can simply drop this trait into any test that deals with a feature which should be limited to admins and have everything grouped together nicely.

While this approach doesn’t completely eliminate duplication, it reduces it to an acceptable amount. The most important thing is that the test is defined in a single place. Having to implement the data provider multiple times is a trade-off I’m happy to make.

Deactivating middleware in tests

The last thing I want to talk about is deactivating middleware in our route tests. To explain why this is something you might want to do, let’s take a look at a slightly more complicated middleware than before.

One of my projects is an app that allows you to play Microscope online for free. I want to make sure that only users which are players in the game can interact with it. This used to be fairly straight forward since every player required an account on the site. It became more complicated when I added the option to mark games as “public”. Public games could be joined by someone without an account as well, provided they knew the invitation link to the game.

To check whether or not someone is allowed to interact with the game, I wrote this middleware:

class MicroscopeMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        /** @var History $history */
        if (($history = $request->route('history')) === null) {
            return $next($request);
        }

        /** @var MicroscopePlayer $player */
        $player = $request->user('microscope');

        if (!$history->public && $player->isGuest()) {
            abort(403);
        }

        if (!$player->isPlayer($history)) {
            abort(403);
        }

        return $next($request);
    }
}

You don’t have to understand all the specifics of this middleware but let’s go through the important bits really quickly.

    return $next($request);
}

/** @var MicroscopePlayer $player */
$player = $request->user('microscope');

if (!$history->public && $player->isGuest()) {

Checking if the user is a player in the game works differently for logged-in users and guests. I added an AnonymousPlayer class that represents a guest. Both the User model and the AnonymousPlayer class implement a MicroscopePlayer interface. This interface is what gets returned by the $request->user('microscope') call. This way, the middleware doesn’t have to differentiate between authenticated and unauthenticated requests.

Next, we check if a guest is trying to access a non-public game.

$player = $request->user('microscope');

if (!$history->public && $player->isGuest()) {
    abort(403);
}

if (!$player->isPlayer($history)) {

Since guests can only join public games, we bail out.

Lastly, we check if the user is a player in the game they are trying to access. If not, we return a Forbidden response.

    abort(403);
}

if (!$player->isPlayer($history)) {
    abort(403);
}

return $next($request);

If all of these checks pass, the user is allowed through and can access whatever route they were trying to access.

The problem

I tested this middleware using the technique described above. Then, in my route tests, I checked that the middleware is actually being used by the correct routes. All bases covered, right?

Let’s look at an example of what such a route test looks like.

class PeriodTest extends TestCase
{
    /** @test */
    public function createPeriodForHistory(): void
    {
        $history = History::factory()->create();
        $player = User::factory()->create();
        $player->joinGame($history);

        $response = $this->actingAs($player)
            ->postJson(route('history.period.store', $history), [
                'name' => '::period-name::',
                'type' => Type::LIGHT,
                'position' => 1,
            ]);

        $response->assertStatus(201);
        $this->assertTrue(
            $history->periods->contains('name': '::period-name::')
        );
        Event::assertDispatched(BoardUpdated::class);
    }
}

Again, don’t worry too much about the specifics of this test. It is essentially a CRUD test. The problem I have with this test are these two lines:

$history = History::factory()->create();
$player = User::factory()->create();
$player->joinGame($history);

$response = $this->actingAs($player)

What I want to test if I can create a Period inside a History. But because this route uses the MicroscopeMiddleware shown above, any request I send needs to be from a user that is a player in that game. Otherwise the middleware would return a 403. This doesn’t have anything to do with what this test is about. It’s just so I get through the middleware to the actual controller logic.

I know that the middleware works because it has its own tests. I know that the route uses the middleware because I have tests for that as well. But I still have to include this bit of setup in literally dozens of tests just to satisfy the middleware. This is madness.

Luckily, Laravel provides a way to deactivate certain middleware in our tests. One way to do this is to include the WithoutMiddleware trait in our test like this:

use Illuminate\Foundation\Testing\WithoutMiddleware;

class PeriodTest extends TestCase
{
    use WithoutMiddleware;

    /** @test */
    public function createPeriodForHistory(): void
    {

This would disable all HTTP middleware for all tests inside the PeriodTest class. This is usually not what I want because it would also disable things like route model binding.

To deactivate only a specific middleware, we can instead use the withoutMiddleware() method defined on Laravel’s base TestCase.

class PeriodTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        $this->withoutMiddleware(MicroscopeMiddleware::class);
    }

    /** @test */
    public function createPeriodForHistory(): void
    {

By calling withoutMiddleware inside the setUp method of our test, we can deactivate only the MicroscopeMiddleware. We can now simplify our test from above:

public function createPeriodForHistory(): void
{
    $history = History::factory()->create();

    $response = $this
        ->postJson(route('history.period.store', $history), [
            'name' => '::period-name::',

Because we deactivated the middleware, we no longer have to create a user and have them join the game. In fact, this route doesn’t depend on a user at all so we can simply get rid of all this boilerplate.

When to use

Deactivating middleware in tests is a useful tool to have in your toolbox. It’s not something I reach for all the time, however. For example, I probably wouldn’t do this for the AdminMiddleware from above. I don’t have a hard and fast rule for this, but it usually depends on how much I think getting through the middleware detracts from the actual test.

Conclusion

If you jumped all the way down to the conclusion in the hopes of a tl;dr, first of all, shame on you. Second, here’s the tl;dr:

  • We can test middleware in isolation by defining a dummy route in the setUp method of our test and attaching the middleware to it
  • This way, we can use Laravel’s HTTP test helpers but don’t have to use an actual application route
  • By using this package, we can test that a route uses a certain middleware
  • As an optional step, we can deactivate the middleware in our route tests to reduce the amount of setup required

That’s it folks, that’s the post. I hope you found my ramblings useful. If you have any comments, feel free to reach out to me on Twitter.