Testing Laravel Middleware
Published by Matthew Daly 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<?php23namespace App\Http\Middleware;45use Closure;67class RedirectFromAdminMiddleware8{9 /**10 * Handle an incoming request.11 *12 * @param \Illuminate\Http\Request $request13 * @param \Closure $next14 * @return mixed15 */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<?php23use Illuminate\Http\Request;45class RedirectFromAdminMiddlewareTest extends TestCase6{7 public function testRedirectMiddlewareCalledOnAdmin()8 {9 // Create request10 $request = Request::create('http://example.com/admin', 'GET');1112 // Pass it to the middleware13 $middleware = new App\Http\Middleware\RedirectFromAdminMiddleware();14 $response = $middleware->handle($request, function () {});15 $this->assertEquals($response->getStatusCode(), 302);16 }1718 public function testRedirectMiddlewareNotCalledOnNonAdmin()19 {20 // Create request21 $request = Request::create('http://example.com/pages', 'GET');2223 // Pass it to the middleware24 $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<?php23namespace App\Http\Middleware;45use Closure;67class ETagMiddleware {8 /**9 * Implement Etag support10 *11 * @param \Illuminate\Http\Request $request12 * @param \Closure $next13 * @return mixed14 */15 public function handle($request, Closure $next)16 {17 // Get response18 $response = $next($request);19 // If this was a GET request...20 if ($request->isMethod('get')) {21 // Generate Etag22 $etag = md5($response->getContent());23 $requestEtag = str_replace('"', '', $request->getETags());24 // Check to see if Etag has changed25 if($requestEtag && $requestEtag[0] == $etag) {26 $response->setNotModified();27 }28 // Set Etag29 $response->setEtag($etag);30 }31 // Send response32 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<?php23use Illuminate\Http\Request;45class ETagMiddlewareTest extends TestCase6{7 /**8 * Test new request not cached9 *10 * @return void11 */12 public function testModified()13 {14 // Create mock response15 $response = Mockery::mock('Illuminate\Http\Response')->shouldReceive('getContent')->once()->andReturn('blah')->getMock();16 $response->shouldReceive('setEtag')->with(md5('blah'));1718 // Create request19 $request = Request::create('http://example.com/admin', 'GET');2021 // Pass it to the middleware22 $middleware = new App\Http\Middleware\ETagMiddleware();23 $middlewareResponse = $middleware->handle($request, function () use ($response) {24 return $response;25 });26 }2728 /**29 * Test repeated request not modified30 *31 * @return void32 */33 public function testNotModified()34 {35 // Create mock response36 $response = Mockery::mock('Illuminate\Http\Response')->shouldReceive('getContent')->once()->andReturn('blah')->getMock();37 $response->shouldReceive('setEtag')->with(md5('blah'));38 $response->shouldReceive('setNotModified');3940 // Create request41 $request = Request::create('http://example.com/admin', 'GET', [], [], [], [42 'ETag' => md5('blah')43 ]);4445 // Pass it to the middleware46 $middleware = new App\Http\Middleware\ETagMiddleware();47 $middlewareResponse = $middleware->handle($request, function () use ($response) {48 return $response;49 });50 }5152 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.