Ok, so we've created all the functions, but did you think I will leave you without automated testing? We need to make sure that our API is working now, and also will not break with future changes.
Our goal is to cover all endpoints with tests, some of them with success/failure scenarios.
Notice: if you haven't written any tests before, you can also watch my full 2-hour course Laravel Testing for Beginners.
First, we need to prepare the testing database for our tests. For this simple example, I will use SQLite in-memory database, so in the phpunit.xml
that comes by default with Laravel, we need to just un-comment what's already there: the variables of DB_CONNECTION
and DB_DATABASE
.
phpunit.xml
<php> <env name="APP_ENV" value="testing"/> <env name="BCRYPT_ROUNDS" value="4"/> <env name="CACHE_DRIVER" value="array"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/>
Now, whatever DB operations will be executed in our tests, they will not touch our main database, but rather will execute in memory, in a temporary database.
Now, let's start writing tests, in roughly the same order as we created this app - from the authentication layer. We create a feature test for auth:
php artisan make:test AuthenticationTest
And here are our first few simple tests:
tests/Feature/AuthenticationTest.php:
namespace Tests\Feature; use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class AuthenticationTest extends TestCase{ use RefreshDatabase; public function testUserCanLoginWithCorrectCredentials() { $user = User::factory()->create(); $response = $this->postJson('/api/v1/auth/login', [ 'email' => $user->email, 'password' => 'password', ]); $response->assertStatus(201); } public function testUserCannotLoginWithIncorrectCredentials() { $user = User::factory()->create(); $response = $this->postJson('/api/v1/auth/login', [ 'email' => $user->email, 'password' => 'wrong_password', ]); $response->assertStatus(422); }}
First, we need use RefreshDatabase;
so the database would be re-migrated fresh before each test. Just don't forget to change to the testing database beforehand!
Next, each method has three phases:
As you can see, we have TWO tests for the same login endpoint, and that's pretty important. You need to test not only the success scenario but also that the incorrect or invalid request actually fails with the correct errors.
Of course, on top of that, you can test the actual content of the response, but even those tests above would be pretty sufficient as a starter point.
Now, I will delete the ExampleTest files that come with Laravel so they wouldn't be listed in the running tests: tests/Feature/ExampleTest.php
and tests/Unit/ExampleTest.php
files. By the way, we will create a Unit test, too, a bit later.
And, if we run the php artisan test
, we have two tests passed successfully!
Now, let's test the registration endpoint, within the same file.
tests/Feature/AuthenticationTest.php:
public function testUserCanRegisterWithCorrectCredentials(){ $response = $this->postJson('/api/v1/auth/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password', 'password_confirmation' => 'password', ]); $response->assertStatus(201) ->assertJsonStructure([ 'access_token', ]); $this->assertDatabaseHas('users', [ 'name' => 'John Doe', 'email' => 'john@example.com', ]);} public function testUserCannotRegisterWithIncorrectCredentials(){ $response = $this->postJson('/api/v1/auth/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password', 'password_confirmation' => 'wrong_password', ]); $response->assertStatus(422); $this->assertDatabaseMissing('users', [ 'name' => 'John Doe', 'email' => 'john@example.com', ]);}
As you can see, in the first method we test not only the HTTP Status 200 but also that it has the token returned and also test if the new record appears in the database.
The opposite validation test checks if the response has a 422 validation code and doesn't save the user into the database.
And that's it for the Authentication Test! The next one is the profile:
php artisan make:test ProfileTest
There won't be anything really new or groundbreaking, so I will just paste the full code here:
tests/Feature/ProfileTest.php:
namespace Tests\Feature; use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class ProfileTest extends TestCase{ use RefreshDatabase; public function testUserCanGetTheirProfile() { $user = User::factory()->create(); $response = $this->actingAs($user)->getJson('/api/v1/profile'); $response->assertStatus(200) ->assertJsonStructure(['name', 'email']) ->assertJsonCount(2) ->assertJsonFragment(['name' => $user->name]); } public function testUserCanUpdateNameAndEmail() { $user = User::factory()->create(); $response = $this->actingAs($user)->putJson('/api/v1/profile', [ 'name' => 'John Updated', 'email' => 'john_updated@example.com', ]); $response->assertStatus(202) ->assertJsonStructure(['name', 'email']) ->assertJsonCount(2) ->assertJsonFragment(['name' => 'John Updated']); $this->assertDatabaseHas('users', [ 'name' => 'John Updated', 'email' => 'john_updated@example.com', ]); } public function testUserCanChangePassword() { $user = User::factory()->create(); $response = $this->actingAs($user)->putJson('/api/v1/password', [ 'current_password' => 'password', 'password' => 'testing123', 'password_confirmation' => 'testing123', ]); $response->assertStatus(202); }}
The same logic: we create a fake user, make the API request, and check the response. A few new assertion methods for you are assertJsonStructure()
, assertJsonCount()
, assertJsonFragment()
, and assertNoContent()
but I think there's not much to explain about them, it's almost like you would read the English text. But you can read more about them in the official docs here.
In this case, I haven't written the "negative" tests for non-ideal scenarios: that profile shouldn't be accessed by an unauthenticated user, or the validation error appears in case of invalid data sent. This is intentional: I'm leaving it for you as homework! Or did you think that learning to code is just a read-only process, huh? :)
If we run the php artisan test
now, our test suite is getting bigger:
Next, we're testing three more endpoint groups: Vehicles, Zones, and Parkings.
php artisan make:test ZoneTest
tests/Feature/ZoneTest.php:
namespace Tests\Feature; use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class ZoneTest extends TestCase{ use RefreshDatabase; public function testPublicUserCanGetAllZones() { $response = $this->getJson('/api/v1/zones'); $response->assertStatus(200) ->assertJsonStructure(['data']) ->assertJsonCount(3, 'data') ->assertJsonStructure(['data' => [ ['*' => 'id', 'name', 'price_per_hour'], ]]) ->assertJsonPath('data.0.id', 1) ->assertJsonPath('data.0.name', 'Green Zone') ->assertJsonPath('data.0.price_per_hour', 100); }}
Only one method in this test, and we check if we get the three zones that we had seeded in the migrations (remember?). Here, again, a new assertion method assertJsonPath()
, and also a new syntax with an asterisk (*) if there are multiple records returned.
Next?
php artisan make:test VehicleTest
To create fake vehicles, we also need to create a factory class.
php artisan make:factory VehicleFactory --model=Vehicle
We fill it in with just one field plate_number
that can be random text:
database/factories/VehicleFactory.php:
namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; class VehicleFactory extends Factory{ public function definition() { return [ 'plate_number' => strtoupper(fake()->randomLetter()) . fake()->numberBetween(100, 999) ]; }}
And then, we write the first method to get users their own vehicles, also testing that API doesn't return the vehicles of another user.
namespace Tests\Feature; use Tests\TestCase;use App\Models\Vehicle;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase; class VehicleTest extends TestCase{ use RefreshDatabase; public function testUserCanGetTheirOwnVehicles() { $john = User::factory()->create(); $vehicleForJohn = Vehicle::factory()->create([ 'user_id' => $john->id ]); $adam = User::factory()->create(); $vehicleForAdam = Vehicle::factory()->create([ 'user_id' => $adam->id ]); $response = $this->actingAs($john)->getJson('/api/v1/vehicles'); $response->assertStatus(200) ->assertJsonStructure(['data']) ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.plate_number', $vehicleForJohn->plate_number) ->assertJsonMissing($vehicleForAdam->toArray()); }}
As you can see, here we use API Resources that automatically add the "data" layer in the JSON response, so we need to test the structure within that "data".
Also, when creating the records with factories, you can override or add any field that is not defined in the factory rules, like user_id
in our case here.
Everything else should be familiar to you.
A few more methods in the same class, testing other endpoints, and here's the full class:
tests/Feature/VehicleTest.php:
namespace Tests\Feature; use Tests\TestCase;use App\Models\Vehicle;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase; class VehicleTest extends TestCase{ use RefreshDatabase; public function testUserCanGetTheirOwnVehicles() { $john = User::factory()->create(); $vehicleForJohn = Vehicle::factory()->create([ 'user_id' => $john->id ]); $adam = User::factory()->create(); $vehicleForAdam = Vehicle::factory()->create([ 'user_id' => $adam->id ]); $response = $this->actingAs($john)->getJson('/api/v1/vehicles'); $response->assertStatus(200) ->assertJsonStructure(['data']) ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.plate_number', $vehicleForJohn->plate_number) ->assertJsonMissing($vehicleForAdam->toArray()); } public function testUserCanCreateVehicle() { $user = User::factory()->create(); $response = $this->actingAs($user)->postJson('/api/v1/vehicles', [ 'plate_number' => 'AAA111', ]); $response->assertStatus(201) ->assertJsonStructure(['data']) ->assertJsonCount(2, 'data') ->assertJsonStructure([ 'data' => ['0' => 'plate_number'], ]) ->assertJsonPath('data.plate_number', 'AAA111'); $this->assertDatabaseHas('vehicles', [ 'plate_number' => 'AAA111', ]); } public function testUserCanUpdateTheirVehicle() { $user = User::factory()->create(); $vehicle = Vehicle::factory()->create(['user_id' => $user->id]); $response = $this->actingAs($user)->putJson('/api/v1/vehicles/' . $vehicle->id, [ 'plate_number' => 'AAA123', ]); $response->assertStatus(202) ->assertJsonStructure(['plate_number']) ->assertJsonPath('plate_number', 'AAA123'); $this->assertDatabaseHas('vehicles', [ 'plate_number' => 'AAA123', ]); } public function testUserCanDeleteTheirVehicle() { $user = User::factory()->create(); $vehicle = Vehicle::factory()->create(['user_id' => $user->id]); $response = $this->actingAs($user)->deleteJson('/api/v1/vehicles/' . $vehicle->id); $response->assertNoContent(); $this->assertDatabaseMissing('vehicles', [ 'id' => $vehicle->id, 'deleted_at' => NULL ])->assertDatabaseCount('vehicles', 1); // we have SoftDeletes, remember? }}
The final feature test is for parkings.
php artisan make:test ParkingTest
Here, we will have three tests, in three methods: for starting the parking, for getting the correct price after X hours, and for stopping the parking.
tests/Feature/ParkingTest.php:
use App\Models\Parking;use App\Models\User;use App\Models\Vehicle;use App\Models\Zone;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase; class ParkingTest extends TestCase{ use RefreshDatabase; public function testUserCanStartParking() { $user = User::factory()->create(); $vehicle = Vehicle::factory()->create(['user_id' => $user->id]); $zone = Zone::first(); $response = $this->actingAs($user)->postJson('/api/v1/parkings/start', [ 'vehicle_id' => $vehicle->id, 'zone_id' => $zone->id, ]); $response->assertStatus(201) ->assertJsonStructure(['data']) ->assertJson([ 'data' => [ 'start_time' => now()->toDateTimeString(), 'stop_time' => null, 'total_price' => 0, ], ]); $this->assertDatabaseCount('parkings', '1'); } public function testUserCanGetOngoingParkingWithCorrectPrice() { $user = User::factory()->create(); $vehicle = Vehicle::factory()->create(['user_id' => $user->id]); $zone = Zone::first(); $this->actingAs($user)->postJson('/api/v1/parkings/start', [ 'vehicle_id' => $vehicle->id, 'zone_id' => $zone->id, ]); $this->travel(2)->hours(); $parking = Parking::first(); $response = $this->actingAs($user)->getJson('/api/v1/parkings/' . $parking->id); $response->assertStatus(200) ->assertJsonStructure(['data']) ->assertJson([ 'data' => [ 'stop_time' => null, 'total_price' => $zone->price_per_hour * 2, ], ]); } public function testUserCanStopParking() { $user = User::factory()->create(); $vehicle = Vehicle::factory()->create(['user_id' => $user->id]); $zone = Zone::first(); $this->actingAs($user)->postJson('/api/v1/parkings/start', [ 'vehicle_id' => $vehicle->id, 'zone_id' => $zone->id, ]); $this->travel(2)->hours(); $parking = Parking::first(); $response = $this->actingAs($user)->putJson('/api/v1/parkings/' . $parking->id); $updatedParking = Parking::find($parking->id); $response->assertStatus(200) ->assertJsonStructure(['data']) ->assertJson([ 'data' => [ 'start_time' => $updatedParking->start_time->toDateTimeString(), 'stop_time' => $updatedParking->stop_time->toDateTimeString(), 'total_price' => $updatedParking->total_price, ], ]); $this->assertDatabaseCount('parkings', '1'); }}
A lot of it will look familiar to you, just a few things to notice:
$this->travel(2)->hours();
allows us to simulate that the current time is actually 2 hours ahead, which means we're stopping the parking "in the future", you can read more about it in the docs.ParkingFactory
for testing, instead creating the parking records directly with JSON requests.