Testing Laravel Middleware

Published by at 29th November 2016 11:00 pm

It's widely accepted that high-level integration tests alone do not make for a good test suite. Ideally each individual component of your application should have unit tests, which test that component in isolation. These unit tests are usually much quicker to run, making it easier to practice test-driven development. However, it can sometimes be hard to grasp how to test that one component on its own.

The other day I had an issue with several middleware classes for a Laravel application and I wanted to verify that they were working as expected. Sounds like a job for dedicated unit tests, but I hadn't tested custom middleware in isolation before, and figuring out how to do so took a while.

Laravel middleware accepts an instance of Illuminate\Http\Request, itself based on the Symfony request object, as well as a closure for the action to take next. Depending on what the middleware does, it may return a redirect or simply amend the existing request or response. So in theory you can instantiate a request object, pass it to the middleware, and check the response. For middleware that does something simple, such as redirecting users based on certain conditions, this is fairly straightforward.

In this example we have a fairly useless piece of middleware that checks to see what the route is for a request and redirects it if it matches a certain pattern:

1<?php
2
3namespace App\Http\Middleware;
4
5use Closure;
6
7class RedirectFromAdminMiddleware
8{
9 /**
10 * Handle an incoming request.
11 *
12 * @param \Illuminate\Http\Request $request
13 * @param \Closure $next
14 * @return mixed
15 */
16 public function handle($request, Closure $next)
17 {
18 if ($request->is('admin*')) {
19 return redirect('/');
20 }
21 return $next($request);
22 }
23}

While this example is of limited use, it wouldn't take much work to develop it to redirect conditionally based on an account type, and it's simple enough to demonstrate the principles involved. In these tests, we create instances of Illuminate\Http\Request and pass them to the middleware's handle() method, along with an empty closure representing the response. If the middleware does not amend the request, we get the empty response from the closure. If it does amend the request, we get a redirect response.

1<?php
2
3use Illuminate\Http\Request;
4
5class RedirectFromAdminMiddlewareTest extends TestCase
6{
7 public function testRedirectMiddlewareCalledOnAdmin()
8 {
9 // Create request
10 $request = Request::create('http://example.com/admin', 'GET');
11
12 // Pass it to the middleware
13 $middleware = new App\Http\Middleware\RedirectFromAdminMiddleware();
14 $response = $middleware->handle($request, function () {});
15 $this->assertEquals($response->getStatusCode(), 302);
16 }
17
18 public function testRedirectMiddlewareNotCalledOnNonAdmin()
19 {
20 // Create request
21 $request = Request::create('http://example.com/pages', 'GET');
22
23 // Pass it to the middleware
24 $middleware = new App\Http\Middleware\RedirectFromAdminMiddleware();
25 $response = $middleware->handle($request, function () {});
26 $this->assertEquals($response, null);
27 }
28}

For middleware that fetches the response and acts on it, things are a little more complex. For instance, this is the Etag middleware I use on many projects:

1<?php
2
3namespace App\Http\Middleware;
4
5use Closure;
6
7class ETagMiddleware {
8 /**
9 * Implement Etag support
10 *
11 * @param \Illuminate\Http\Request $request
12 * @param \Closure $next
13 * @return mixed
14 */
15 public function handle($request, Closure $next)
16 {
17 // Get response
18 $response = $next($request);
19 // If this was a GET request...
20 if ($request->isMethod('get')) {
21 // Generate Etag
22 $etag = md5($response->getContent());
23 $requestEtag = str_replace('"', '', $request->getETags());
24 // Check to see if Etag has changed
25 if($requestEtag && $requestEtag[0] == $etag) {
26 $response->setNotModified();
27 }
28 // Set Etag
29 $response->setEtag($etag);
30 }
31 // Send response
32 return $response;
33 }
34}

This acts on the response object, so we need to pass that through as well. Fortunately, Mockery allows us to create a mock of our response object and set it up to handle only those methods we anticipate being called:

1<?php
2
3use Illuminate\Http\Request;
4
5class ETagMiddlewareTest extends TestCase
6{
7 /**
8 * Test new request not cached
9 *
10 * @return void
11 */
12 public function testModified()
13 {
14 // Create mock response
15 $response = Mockery::mock('Illuminate\Http\Response')->shouldReceive('getContent')->once()->andReturn('blah')->getMock();
16 $response->shouldReceive('setEtag')->with(md5('blah'));
17
18 // Create request
19 $request = Request::create('http://example.com/admin', 'GET');
20
21 // Pass it to the middleware
22 $middleware = new App\Http\Middleware\ETagMiddleware();
23 $middlewareResponse = $middleware->handle($request, function () use ($response) {
24 return $response;
25 });
26 }
27
28 /**
29 * Test repeated request not modified
30 *
31 * @return void
32 */
33 public function testNotModified()
34 {
35 // Create mock response
36 $response = Mockery::mock('Illuminate\Http\Response')->shouldReceive('getContent')->once()->andReturn('blah')->getMock();
37 $response->shouldReceive('setEtag')->with(md5('blah'));
38 $response->shouldReceive('setNotModified');
39
40 // Create request
41 $request = Request::create('http://example.com/admin', 'GET', [], [], [], [
42 'ETag' => md5('blah')
43 ]);
44
45 // Pass it to the middleware
46 $middleware = new App\Http\Middleware\ETagMiddleware();
47 $middlewareResponse = $middleware->handle($request, function () use ($response) {
48 return $response;
49 });
50 }
51
52 public function teardown()
53 {
54 Mockery::close();
55 }
56}

In the first example we mock out the getContent() and setEtag() methods of our response to make sure they get called, and then pass the request to the middleware, along with a closure that returns the response. In the second example, we also mock out setNotModified() to ensure that the correct status code of 304 is set, and add an ETag to our request. In this way we can easily test our middleware in isolation, rather than having to resort to building up our entire application just to test one small method.

Middleware is a convenient place to put functionality that's needed for many routes, but you shouldn't neglect testing it, and ideally you shouldn't have to resort to writing a slow integration test to test it works as expected. By mocking out your dependencies, it's generally not too hard to test it in isolation, resulting in faster and more robust test suites.