
Laravel REST API Part 4: Unit Test Login and Logout APIs
Published on Nov 23, 2024
What is Unit Testing?
Unit testing is a software testing technique that focuses on verifying the smallest parts of an application—called units. The purpose of unit testing is to ensure that each part of the application works as intended in isolation.
Laravel built with test in mind, it provides a convenient way to integrate with Pest (which is a testing framework built by one of Laravel core team) and PHPUnit which what we will be using in this tutorial.
There are two types of test:
Unit Test: Focuses on testing very small part of the application, isolated methods most of the time.
Feature Test: Intended to target larger portion of the code, like testing the whole api from request validation till response, which is what we will be using in this tutorial, it's more practical and cover more scenarios.
Why feature test is important?
From my experience, most of the products I work on was not implementing any kind of code testing at all, or sometimes we have some test coverage but not full coverage and might not be continued.
This could happen usually because most of startup products or a company project, focuses more on launching their products/features fast, and usally they will not have the capacity for bigger team. So, they sacrifice testing in exchange for more features.
Once the product begin to scale, they realize the important of unit testing, because as they continue growing, more people adding more code, and there will be a lot of new features and a lot of refactoring and updates.
Without proper test suit, refactoring could be nightmare, because when you update a portion of code you might affect another code, sometimes when the product is complex enough you don't know which portions are affected. So, it will take more time for QA people to test the whole feature or maybe the whole application when a big refactor happens, imagine also when you have ios and android apps consuming same api.
The point is, as a developer you will not feel confident to refactor or add big change without proper unit testing.
But, yet we didn't solved the main problem for startup's (their priority is to go fast, regardless of you have test or not).
In my humble opinion, we can have balance of both, in other words if going fast is the priority at the moment, then we might have at lease one test case for an api testing the happy scenario and making sure that the intended response is returned successfully to not break client apps.
This is the minimum effort should be added and with time it will not take much time, you will find a lot of similarity in your test cases. Otherwise you will have bigger problems in the future. And this will consume more time and more money.
This is why we introduced Testing in This API series because it should be part of development anyway.
Configure Testing Database
If you follow a long in the first episode when we installed laravel we installed PHPUnit with it. So, we don't need to install it again. We just need to configure it.
In the main directory of the app, you will find phpunit.xml file. There is where we will update our configurations.
We are going to use Sqlite as a testing database, and it's not the same database we use for the application.
Open phpunit.xml file and make sure you have those two lines:
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/>
You'r ready to go, you can find some unit and feature test examples in tests folder, you may run test cases by this command:
php artisan test
Adding Feature Test for Login and Logout API's
Let's create test class first:
php artisan make:test AuthControllerTest
Login testing
For login api we might start with the happy scenario as the following:
use RefreshDatabase; public function test_user_can_login_with_correct_credentials(): void { $user = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@dev-talk.io', 'password' => bcrypt('password'), ]); $response = $this->postJson(route('login'), [ 'email' => $user->email, 'password' => 'password' ]); $response->assertOk() ->assertExactJsonStructure([ 'token', 'user' => [ 'id', 'name', 'email', 'created_at' ], ]); }
In this function we are creating a User and trying to simulate the login api using correct user credentials, and asserting api returns 200 and asserting exact json structure.
But if you run this test case it will fail, because in the login api we just returned plain token, and we need a specific response we might needed while communicating with mobile and frontend developers.
So, we are going to make a change in the api and update it to be able to return the desired response.
Update login API
As we can see in the response we have a token and a user data, which might be organized in a Resource.
So, we are going to create a UserResource for it by running the following command
php artisan make:resource UserResource
A new file under App\Http\Resources will be created.
We are going to add the following data inside toArray function
return [ "id" => $this->id, "name" => $this->name, "email" => $this->email, "created_at" => $this->created_at, ];
And our login api will be like the following:
public function login(Request $request) { $request->validate([ 'email' => 'required|email', 'password' => 'required', ]); $user = User::where('email', $request->email)->first(); if (!$user || !Hash::check($request->password, $user->password)) { throw ValidationException::withMessages([ 'email' => ['The provided credentials are incorrect.'], ]); } $token = $user->createToken($user->name)->plainTextToken; return response()->json([ 'token' => $token, 'user' => new UserResource($user), ]); }
Great, as you can see just one test case introduced an update to the api to make sure that we are returning exactly what we need in the response and it make sure also that the code is working fine we don't have any errors.
You might want to run the test suit now and it will work fine.
Let's add other scenarios
But first, we will do a little refactor as the user creation process will be repeated in other test cases, so we will add it in the Setup function to be able to be used in every test case in our class.
So, we will update the previous code to be like the following:
class AuthControllerTest extends TestCase { use RefreshDatabase; protected $user; public function setUp(): void { parent::setUp(); $this->user = User::factory()->create([ 'name' => 'Test User', 'email' => 'test@dev-talks.io', 'password' => bcrypt('password'), ]); } public function test_user_can_login_with_correct_credentials(): void { $response = $this->postJson(route('login'), [ 'email' => $this->user->email, 'password' => 'password' ]); $response->assertOk() ->assertExactJsonStructure([ 'token', 'user' => [ 'id', 'name', 'email', 'created_at' ], ]); }
Validation test
Let's now add more test cases for different validation scenarios
We might have something like this:
public function test_user_cannot_login_with_wrong_credentials(): void { $response = $this->postJson(route('login'), [ 'email' => $this->user->email, 'password' => 'passwordsdf' ]); $response->assertStatus(422) ->assertJsonValidationErrorFor('email'); } public function test_login_fails_with_missing_email(): void { $response = $this->postJson(route('login'), [ 'password' => 'password' ]); $response->assertStatus(422) ->assertJsonValidationErrorFor('email'); } public function test_login_fails_with_missing_password(): void { $response = $this->postJson(route('login'), [ 'email' => $this->user->email ]); $response->assertStatus(422) ->assertJsonValidationErrorFor('password'); }
We add three test cases trying to test different validation scenarios.
That's very good for login Api, we will stop here and try to test Logout
Testing Logout API
We might have the following scenarios for logout api
public function test_authenticated_user_can_logout(): void { $token = $this->user->createToken($this->user->name)->plainTextToken; $response = $this->withHeaders([ 'Authorization' => 'Bearer ' . $token ])->post(route('logout')); $response->assertStatus(200) ->assertJson([ 'message' => 'user logged out' ]); $this->assertDatabaseMissing('personal_access_tokens', [ 'tokenable' => $this->user->id ]); } public function test_unauthenticated_user_cannot_logout(): void { $response = $this->post(route('logout')); $response->assertStatus(401); }
Two test cases added for logout to test the api working as excepted, and I think they are pretty strait forward I don't have to break them down.
I believe also we are done for this tutorial and hope we learn something by the end of it.
Happy Coding! 👨💻