Tricky Testing with Laravel

I'm building a Laravel application right now and I want to empower applications to authenticate using my application's API layer. I'm using the built-in Auth system and added an api_token field to the user table to turn on token-based auth. I wanted to continue to use the already built and working validation, rate limiting, and auth checking from the auth module that ships with Laravel.

Clients should be able to send a POST request to /api/session with email and password and receive a response with the user object and that user's api token.

Here's what it took to build this functionality and test it.

The Controller

First, I needed a controller. Fortunately, the login controller that comes with Laravel is encapsulated within a trait, which makes it easy to apply to any controller you might want. All I had to do here was override the authenticated method to return a response and it works as expected for successful login attempts (in the browser/postman):

// app/Http/Controllers/Api/Session.php
<?php

namespace App\Http\Controllers\Api;

use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class Session extends Controller
{

    use AuthenticatesUsers;

    protected function authenticated(Request $request, $user)
    {
        return response()->json([
            'token' => $user->api_token,
            'user' => $user,
        ], 200);
    }

}

The failure conditions still needed some work though. Validation errors, lockouts, and invalid credentials all kept trying to return HTML instead of JSON. The solution for that problem was to handle those differently in my exception handler class.

The Exception Handler

Laravel has an exception handler that will turn exceptions in the request into HTTP responses. The problem is that some of these exceptions are specifically meant to translate well into a response (e.g. HttpException), while others are somewhat easy to translate, while still others do not lend themselves well. For example, a ValidationException will have a status code in the status property that corresponds to its HTTP status code (usually), but in order to get the validation error messages, you have to access them from the errors() method rather than getMesage(). Or an authentication exception (meaning the route is guarded but you're not authenticated) has no status and normally results in a redirect to /login, so it needs to be replaced with a 401 status and a message that the user is unauthenticated. This is all for good reason―it's just that I had to handle all these different exceptions individually by type.

// app/Exceptions/Handler.php
<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;

class Handler extends ExceptionHandler
{

    // ...

    public function render($request, Exception $exception)
    {
        if ($request->wantsJson() || preg_match('@^/api/@', $request->getRequestUri())) {
            $response = [
                'errors' => [$exception->getMessage()],
            ];
            try {
                throw $exception;
            } catch (HttpException $e) {
                $status = $e->getStatusCode();
            } catch (AuthenticationException $e) {
                $status = 401;
            } catch (ValidationException $e) {
                $status = $e->status;
                $response['errors'] = $e->errors();
            } catch (Throwable $e) {
                $status = 500;
            }

            return response()->json($response, $status);
        }

        return parent::render($request, $exception); // @codeCoverageIgnore
    }
}

This is all well and good, and it works when tested in a client. But I want automated tests bundled with the application.

The Feature Tests

This is where things got really weird. Well, sort of. The tests for validation errors and failed logins were super simple and had no problems at all.

Testing the login throttling was a bit trickier. Since the rate limiter uses whatever is set for the Cache Repository interface, I tried at first to mock the cache using the global facade:

Cache::shouldReceive('get')
    ->once()
    ->with('test@example.com|8.8.8.8')
    ->andReturn(99);
// Etc. for the timer checks

Unfortunately, that only mocks the cache store object, not a cache repository. So the container happily injected a non-mocked repository into the limiter and ignored my facade mock.

Next I tried overriding the RateLimiter in the container to always return true when asked if the rate limit had been reached, but that resulted in triggering the rate limiting middleware for the API in general, not the rate limiting specific to the login route. That's not what I'm trying to test either.

I finally settled on setting a singleton for the cache repository interface that returned a mockery object that was expecting those specific calls for the login rate limiting. This worked quite well, but was just a lot more involved than I'd expected. Not a problem though―move on.

Testing the login itself was really painful. The login handler expects the request to have a Laravel session set. Presumably this is to add the credentials to the session, but it doesn't really matter. This test kept failing with a complaint about the lack of a session. Here's what I had for a test:

public function testSuccessfulLoginReturnsApiToken()
{
    $user = factory(User::class)->create(['password' => bcrypt('foobar')]);
    $response = $this->postJson('/api/session', ['email' => $user->email, 'password' => 'foobar']);
    $response->assertStatus(200)
    $response->assertJson(['token' => $user->api_token, 'user' => $user->toArray()]);
}

The root issue here is that sessions are not injected into requests in the constructor, but via setters. Although Laravel makes it easy to put test data into the session store in the container, the authentication route doesn't use the container to resolve a session when there's no session contained in the request. Furthermore, the request is not being generated by the container, but directly by the send method of the MakesHttpRequests trait. It doesn't set the session there, so there isn't an easy way to inject it without overriding that method (or so I thought at first).

It took me a few iterations before I finally landed on the final (and in my opinion, fairly elegant) solution below. I was doing things like overriding methods from the traits, etc. The kind of thing that will break sooner or later.

In the end, I realized that I did have a class that I could replace fairly easily to modify the request before it gets sent: the Kernel class. So I ended up using an anonymous class to extend my Kernel and wrap the handle method such that I could inject a session into the request and then pass it to the parent's handler.

// tests/Feature/Api/SessionControllerTest.php
<?php

namespace Tests\Feature\Api;

use App\Http\Kernel;
use App\User;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Tests\TestCase;

class SessionControllerTest extends TestCase
{

    use RefreshDatabase;

    public function testValidationReturnsJsonOnFailure()
    {
        $response = $this->post('/api/session');
        $response->assertStatus(422);
        $response->assertJson(
            [
                "errors" => [
                    "email" => ["The email field is required."],
                    "password" => ["The password field is required."]
                ]
            ]
        );
    }

    public function testLoginThrottlingIsJson()
    {
        $this->app->singleton(Repository::class, function () {
            $mock = \Mockery::mock(Repository::class);
            $mock->shouldReceive('get')
                ->with('test@example.com|8.8.8.8', 0)
                ->andReturn(99);
            $mock->shouldReceive('has')
                ->with('test@example.com|8.8.8.8:timer')
                ->andReturn(true);
            $mock->shouldReceive('get')
                ->with('test@example.com|8.8.8.8:timer')
                ->andReturn(time() + 60);
            $mock->shouldIgnoreMissing();
            return $mock;
        });

        $response = $this->withServerVariables(['REMOTE_ADDR' => '8.8.8.8'])
            ->postJson('/api/session', ['email' => 'test@example.com', 'password' => 'foobar']);

        $response->assertJson([
            'errors' => [
                'email' => ['Too many login attempts. Please try again in 60 seconds.']
            ]
        ]);
        $response->assertStatus(423);
    }

    public function testFailedLoginIsJson()
    {
        $user = factory(User::class)->create(['password' => bcrypt('foobar')]);
        $response = $this->postJson('/api/session', ['email' => $user->email, 'password' => 'bazbat']);
        $response->assertStatus(422);
        $response->assertJson(['errors' => ['email' => ['These credentials do not match our records.']]]);
    }

    public function testSuccessfulLoginReturnsApiToken()
    {
        $this->app->singleton(HttpKernel::class, function ($app) {
            return new class($app, $app['router']) extends Kernel
            {
                public function handle($request)
                {
                    $request->setLaravelSession($this->app['session']);
                    return parent::handle($request);
                }
            };
        });
        $user = factory(User::class)->create(['password' => bcrypt('foobar')]);
        $this->flushSession();
        $response = $this->postJson('/api/session', ['email' => $user->email, 'password' => 'foobar']);
        $response->assertStatus(200);
        $response->assertJson([
            'token' => $user->api_token,
            'user' => $user->toArray(),
        ]);
    }

}

My Takeaways

  1. Laravel has some excellent tools for writing simple feature tests without much hassle. None of these tests require any special setup or services to be running.
  2. Code smells uncovered in testing (e.g. overriding methods internal to Laravel's testing tools to inject a session into the request) can easily come from your own test code itself. Try to find a simpler way to do it before blaming the tools.
  3. Anonymous classes are awesome.