Php Unit Testing Jwt Authenticated Apis In Laravel, The Easy Way Enter a brief summary for this post

PHP Unit Testing JWT Authenticated APIs in Laravel, the Easy way

By no stretch of the imagination am I an expert in unit testing with PHP Unit but I did manage to help a fellow developer out in one of those Facebook groups recently. As I continue to do unit testing, I find more confidence in the practice and it spurs me on to continue with unit testing ― mitigating the idea of it being a chore or unnecessary.

This blog post is to demonstrate an easier way of testing the JWT authentication in Laravel and to also test a user is authenticated but without going through the steps to authenticate. Really, it's an improvement on the way I would have done the user authentication in the past so if you are new to PHP unit testing and/or JWT usage then this will help you I imagine.

The JWT Authentication Layer

Starting with the controller which is below.

namespace App\Http\Controllers\Api\v1;

use Log;
use JWTAuth;
use App\Models\User;
use Illuminate\Support\Str;
use App\Services\UserService;
use App\Repositories\UserRepository;
use Tymon\JWTAuth\Exceptions\JWTException;
use App\Exceptions\ServiceFaultException;
use Illuminate\Database\Eloquent\ModelNotFoundException;

use Illuminate\Support\Facades\Auth;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

/**
 * @note    can use the following to access your token, and the user based on a token, once 
 *          authenticated
 * 
 *          - $token=JWTAuth::fromUser(auth()->guard('api')->user()) returns the token
 *          - $user=JWTAuth::toUser($token) and 
 *          - auth()->guard('api')->user() both return the user
 * 
 *          can set a token also, using JWTAuth::setToken($token);
 */

class AuthenticateController extends Controller
{
    protected UserService $userService;

    public function __construct(UserService $userService) 
    {
        $this->userService = $userService;
    }

    protected function getUserService() : UserService
    {
        return $this->userService;
    }

    public function refresh() : JsonResponse
    {   
        try
        {   
            /**
             * @note    pass true for first parameter, this forces the token to be blacklisted, 
             *          and true for the second parameter, to reset any claims for the new token
             * 
             *          - keep blacklisted enabled, it's recommended
             *          - keep blacklisted grace period to 30 seconds
             * 
             * @see     https://github.com/tymondesigns/jwt-auth/blob/2.x/docs/auth-guard.md
             */

            return $this->respondWithToken(auth()->guard('api')->refresh(true, true), 200);
        } 
        catch(JWTException $e)
        {   
            return response()->json([
                'status'=>false
              , 'message'=>'Unauthorized'
              , 'errors'=>$e->getMessage()
            ], 401)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }
    }
    
    public function logout(Request $request) : JsonResponse
    {   
        auth()->guard('api')->logout(true);

        return response()->json([
            'status'=>true
          , 'message'=>'Successfully logged out'
        ], 200)
        ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
    }

    public function login(Request $request) : JsonResponse
    {   
        try 
        {
            $result=$this->getUserService()->login($request->only([
                    'email'
                  , 'password'
                ])
            );
        }
        catch(ServiceFaultException $e)
        {
            return response()->json([
                'status'=>false
              , 'message'=>'This service is not available'
              , 'errors'=>$e->getMessage()
            ], 500)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        if(is_array($result))
        {   
            return response()->json([
                'status'=>false
              , 'message'=>'Please correct form validation errors'
              , 'errors'=>$result
            ], 409)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        if(!$token=auth()->guard('api')->attempt(['email'=>$request->email, 'password'=>$request->password])) 
        {   
            return response()->json([
                'status'=>false
              , 'message'=>'Unauthorized'
            ], 401)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        return $this->respondWithToken($token, 200);
    }

    public function register(Request $request) : JsonResponse 
    {
        $request->merge([
            'active'=>false,
        ]);

        try 
        {
            $result=$this->getUserService()->register($request->only([
                    'fullname'
                  , 'email'
                  , 'active'
                  , 'password'
                ])
            );
        }
        catch(ServiceFaultException $e)
        {
            return response()->json([
                'status'=>false
              , 'message'=>'This service is not available'
              , 'errors'=>$e->getMessage()
            ], 500)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        if(is_array($result))
        {   
            return response()->json([
                'status'=>false
              , 'message'=>'Please correct form validation errors'
              , 'errors'=>$result
            ], 409)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        $token = JWTAuth::fromUser($result);
        return response()->json([
            'status'=>true
          , 'message'=>'User successfully registered'
          , 'token'=>$token
          , 'token_type'=>'bearer'
          , 'user'=>$result
        ], 201)
        ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
    }

    protected function respondWithToken(string $token, int $code) 
    {
        /**
         * @note    this expiry, use with a cookie on the client side, and to send back 
         *          details of authenticated user in response, use
         *      
         *          - auth()->guard('api')->user()
         * 
         * @note    send with the response no cache headers, preventing any endpoint being cached 
         *          by the browser, @see https://github.com/tymondesigns/jwt-auth/issues/983
         */

        $expiry=auth()->guard('api')->factory()->getTTL() * 60; 

        return response()->json([
            'status'=>true
          , 'token'=>$token
          , 'token_type'=>'bearer'
          , 'expiry'=>$expiry
          , 'user'=>auth()->guard('api')->user()
        ], $code)
        ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
    } 
}

I have other blog posts about using this Laravel JWT authentication with React, which may interest you too? Go browse. The unit tests for this controller now follow below.

namespace Tests\Feature;

use JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;

use Log;
use App\Models\User;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Testing\Fakes\EventFake;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class AuthenticateControllerTest extends TestCase
{
    use RefreshDatabase;

    protected string $endpoint='/api/v1';

    public function setUp() : void
    {
        parent::setUp();
    }

    public function tearDown() : void
    {
        parent::tearDown();
    }

    /** @test **/
    public function user_registration_required_fields() : void
    {
        $this->withExceptionHandling();

        $response=$this->json('POST', $this->endpoint.'/authenticate/register', [], [
            'Accept'=>'application/json'
        ]);
        
        $response->assertStatus(409);
        $response->assertJsonStructure([
            'status',
            'message',
            'errors' => [
                'fullname',
                'email',
                'password',
            ]
        ]);
    }

    /** @test **/
    public function can_a_new_user_register() : void
    {
        $this->withExceptionHandling();

        $user=User::factory()->make();
        $response=$this->json('POST', $this->endpoint.'/authenticate/register', [
            'fullname'=>$user->fullname,
            'email'=>$user->email,
            'password'=>'password'
        ], ['Accept'=>'application/json']);
        
        $response->assertStatus(201);
        $response->assertJsonStructure([
            'status'
          , 'user'=>[
                'id'
              , 'fullname'
              , 'email'
            ]
        ]);

        $this->assertDatabaseHas('users', [
            'fullname'=>$user->fullname,
            'email'=>$user->email
        ]);
    }

    /** @test **/
    public function user_login_required_fields() : void
    {
        $this->withExceptionHandling();

        $response=$this->json('POST', $this->endpoint.'/authenticate/login', [], [
            'Accept'=>'application/json'
        ]);
        
        $response->assertStatus(409);
        $response->assertJsonStructure([
            'status',
            'message',
            'errors' => [
                'email',
                'password',
            ]
        ]);
    }

    /** @test **/
    public function can_a_registered_user_login() : void
    {
        $this->withExceptionHandling();

        $user=User::factory()->create(); 
        $response=$this->json('POST', $this->endpoint.'/authenticate/login', [
            'email'=>$user->email,
            'password'=>'password'
        ], ['Accept'=>'application/json']);
        
        $this->assertAuthenticated('api');

        $response->assertStatus(200);
        $response->assertJsonStructure([
            'status',
            'token',
            'token_type',
            'expiry',
            'user'=>[
                'id',
                'fullname',
                'email',
            ]
        ]);
    }

    /** @test **/
    public function a_fake_user_cannot_login() : void
    {
        $this->withExceptionHandling();

        $response=$this->json('POST', $this->endpoint.'/authenticate/login', [
            'email'=>'fake@bt.com'
          , 'password'=>'password'
        ], ['Accept'=>'application/json']);
        
        $this->assertGuest('api');

        $response->assertStatus(401);
        $response->assertJsonStructure([
            'status'
        ]);

        $content=json_decode($response->getContent(), true);

        $this->assertTrue($content['status'] === false);
    }

    /** @test **/
    public function can_an_authenticated_user_logout() : void
    {
        $this->withExceptionHandling();

        $user=User::factory()->create(); 
        $response=$this->json('POST', $this->endpoint.'/authenticate/login', [
            'email'=>$user->email
          , 'password'=>'password'
        ], ['Accept'=>'application/json'])
        ->assertStatus(200)
        ->assertJsonStructure([
            'status',
            'token'
        ]);
        
        $this->assertAuthenticated('api');

        $response=$this->json('POST', $this->endpoint.'/authenticate/logout', [], ['Accept'=>'application/json']);
        $response->assertStatus(200)
            ->assertJsonStructure([
                'status'
              , 'message'
            ]);

        $this->assertGuest('api');
    }

    /** @test **/
    public function can_a_user_refresh_their_token() : void
    {
        $this->withExceptionHandling();

        $user=User::factory()->create(); 
        $response=$this->json('POST', $this->endpoint.'/authenticate/login', [
            'email'=>$user->email
          , 'password'=>'password'
        ], ['Accept'=>'application/json']);
        
        $this->assertAuthenticated('api');

        $response->assertStatus(200);
        $response->assertJsonStructure([
            'status'
          , 'token'
          , 'token_type'
          , 'expiry'
          , 'user'=>[
                'id'
              , 'fullname'
              , 'email'
            ]
        ]);

        $content=json_decode($response->getContent(), true);
        $response=$this->json('POST', $this->endpoint.'/authenticate/refresh', [
            'email'=>$user->email
          , 'password'=>'password'
        ], [
            'Accept'=>'application/json', 
            'Authorization'=>'Bearer '.$content['token']
        ]);
        
        $response->assertStatus(200);
        $response->assertJsonStructure([
            'status'
          , 'token'
          , 'token_type'
          , 'expiry'
          , 'user'=>[
                'id'
              , 'fullname'
              , 'email'
            ]
        ]);
    }

    /** @test **/
    public function a_registered_user_cannot_register_again_using_the_same_email_address() : void
    {
        $this->withExceptionHandling();

        $user=User::factory()->make(); 
        $response=$this->json('POST', $this->endpoint.'/authenticate/register', [
            'fullname'=>$user->fullname,
            'email'=>$user->email,
            'password'=>'password'
        ], ['Accept'=>'application/json']);
        
        $response->assertStatus(201);
        $response->assertJsonStructure([
            'status',
            'message',
            'user'=>[
                'id',
                'fullname',
                'email',
            ]
        ]);
        
        $this->assertDatabaseHas('users', [
            'fullname'=>$user->fullname
          , 'email'=>$user->email
        ]);

        $response=$this->json('POST', $this->endpoint.'/authenticate/register', [
            'fullname'=>$user->fullname,
            'email'=>$user->email,
            'password'=>'password'
        ], ['Accept'=>'application/json']);
        
        $response->assertStatus(409);
        $response->assertJsonStructure([
            'status',
            'message',
            'errors',
        ]);

        $content=json_decode($response->getContent(), true); 
        $this->assertTrue($content['status'] === false);
    }
}

Unit Testing the Authenticated User

PHP Unit is a great testing framework because one of its glowing features is you can act as someone else: you can be them without actually being troubled too much. In other words, you don't have to log in for that user for example ― to gain the JWT token. Say we have a Guide controller for articles. Even though there is no direct database relationship between a User and a Guide the user still must be authenticated to carry out any CRUD actions.

Let's see the Guide controller before the unit tests.

namespace App\Http\Controllers\Api\v1;

use Log;
use JWTAuth;
use App\Services\GuideService;
use App\Exceptions\ServiceFaultException;
use Tymon\JWTAuth\Exceptions\JWTException;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class GuideController extends Controller
{
    protected GuideService $service;

    public function __construct(GuideService $service)
    {
        $this->service = $service;
    }

    public function getUser() : User
    {
        return $this->user=JWTAuth::parseToken()->authenticate();
    }

    public function getService() : GuideService
    {
        return $this->service;
    }

    public function store(Request $request) : JsonResponse
    {   
        $active=($request->active == 'on'? true:false);
        $hidden=($request->hidden == 'on'? true:false);
        $featured=($request->featured == 'on'? true:false);
        $promoted=($request->promoted == 'on'? true:false);

        $request->merge([
            'active'=>$active,
            'hidden'=>$hidden,
            'featured'=>$featured,
            'promoted'=>$promoted,
            'slug'=>$request->title,
            'html'=>$request->body,
            'reading_time'=>$request->body,
        ]);
        
        try
        {
            $res=$this->getService()->create(
                $request->only([
                    'active',
                    'hidden',
                    'featured',
                    'promoted',
                    'body',
                    'html',
                    'seo',
                    'title',
                    'slug',
                    'reading_time',
                ])
            );
        }
        catch(ServiceFaultException $e)
        {
            return response()->json([
                'status'=>false,
                'message'=>'There was a technical fault, please try again',
                'errors'=>[]
            ], 500)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }
        
        if(is_array($res))
        {   
            return response()->json([
                'status'=>false,
                'message'=>'Please correct form validation errors',
                'errors'=>$res
            ], 409)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        return response()->json([
            'status'=>true,
            'message'=>'The database was successfully updated',
            'guide'=>$res
        ], 201)
        ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
    }

    public function update(Request $request) : JsonResponse
    {   
        $active=($request->active == 'on'? true:false);
        $hidden=($request->hidden == 'on'? true:false);
        $featured=($request->featured == 'on'? true:false);
        $promoted=($request->promoted == 'on'? true:false);

        $request->merge([
            'active'=>$active,
            'hidden'=>$hidden,
            'featured'=>$featured,
            'promoted'=>$promoted,
            'slug'=>$request->title,
            'html'=>$request->body,
            'reading_time'=>$request->body,
        ]);
        
        try
        {
            $res=$this->getService()->update(
                $request->only([
                    'active',
                    'hidden',
                    'featured',
                    'promoted',
                    'body',
                    'html',
                    'seo',
                    'title',
                    'slug',
                    'reading_time',
                    'id',
                ])
            );
        }
        catch(ServiceFaultException $e)
        {
            return response()->json([
                'status'=>false,
                'message'=>'There was a technical fault, please try again',
                'errors'=>[]
            ], 500)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }
        
        if(is_array($res))
        {
            return response()->json([
                'status'=>false,
                'message'=>'Please correct form validation errors',
                'errors'=>$res
            ], 409)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }

        return response()->json([
            'status'=>true,
            'message'=>'The database was successfully updated',
            'guide'=>$res,
        ], 201)
        ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
    }

    public function destroy(string $id) : JsonResponse
    {
        try
        {
            $this->getService()->delete($id);
        }
        catch(ServiceFaultException $e)
        {   
            return response()->json([
                'status'=>false,
                'message'=>'There was a technical fault, please try again',
                'errors'=>$e->getMessage()
            ], 500)
            ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
        }
        
        return response()->json([
            'status'=>true,
            'message'=>'This database was successfully updated',
        ], 204)
        ->withHeaders(['Content-Type'=>'application/json; charset=utf-8']);
    }
}

Nothing out of the ordinary. Those unit tests making use of PHP Unit's acting as feature are below and this approach really does make light work for you.

namespace Tests\Feature;

use JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;

use Log;
use App\Models\User;
use App\Models\Guide;
use Illuminate\Support\Str;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Testing\Fakes\EventFake;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class GuideControllerTest extends TestCase
{
    use RefreshDatabase, WithFaker;

    protected string $endpoint='/api/v1';

    public function setUp() : void
    {
        parent::setUp();
    }

    public function tearDown() : void
    {
        parent::tearDown();
    }

    /** @test **/
    public function user_is_authenticated() : void
    {
        $user = User::factory()->create();
        $this->actingAs($user, 'api');
        $this->assertAuthenticated('api');
    }

    /** @test **/
    public function can_authenticated_user_create_a_guide() : void
    {
        $this->withExceptionHandling();

        $user = User::factory()->create();
        $guide = Guide::factory()->make();

        $this->actingAs($user, 'api')
            ->json('POST', $this->endpoint.'/guides/store', $guide->toArray())
            ->assertStatus(201)
            ->assertJsonStructure([
                'status',
                'message',
                'guide'
            ]);

        $this->assertDatabaseHas('guides', [
            'title'=>$guide->title,
            'slug'=>Str::slug($guide->title),
        ]);
    }

    /** @test **/
    public function can_authenticated_user_update_an_existing_guide() : void
    {
        $this->withExceptionHandling();

        $user = User::factory()->create();
        $guide = Guide::factory()->create();

        $this->assertDatabaseHas('guides', [
            'title'=>$guide->title,
            'slug'=>Str::slug($guide->title),
        ]);

        $title = $this->faker->sentence;

        $guide->title = $title;
        $guide->slug = Str::slug($title);

        $this->actingAs($user, 'api')
            ->json('PUT', $this->endpoint.'/guides/update', $guide->toArray())
            ->assertStatus(201)
            ->assertJsonStructure([
                'status',
                'message',
                'guide'
            ]);

        $this->assertDatabaseHas('guides', [
            'title'=>$guide->title,
            'slug'=>Str::slug($guide->title),
        ]);
    }

    /** @test **/
    public function can_authenticated_user_delete_an_existing_guide() : void
    {
        $this->withExceptionHandling();

        $user = User::factory()->create();
        $guide = Guide::factory()->create();

        $this->assertDatabaseHas('guides', [
            'title'=>$guide->title,
            'slug'=>Str::slug($guide->title),
        ]);

        $this->actingAs($user, 'api')
            ->json('DELETE', $this->endpoint.'/guides/destroy/'.$guide->id, [])
            ->assertStatus(204);

        $this->assertDatabaseMissing('guides', [
            'id'=>$guide->id,
        ]);
    }
}

It pays dividends to check out the PHP Unit API now and again and continue to keep going back. It's the same with any API really, you learn something new about it ― each time you walk away you remember something more.