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:
- Arrange (prepare): we create a fake user
- Act (do something): we try to log in
- Assert (check if the result is as expected): we check the status code of the result
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', 'password' => 'password', 'password_confirmation' => 'password', ]); $response->assertStatus(201) ->assertJsonStructure([ 'access_token', ]); $this->assertDatabaseHas('users', [ 'name' => 'John Doe', ]);} public function testUserCannotRegisterWithIncorrectCredentials(){ $response = $this->postJson('/api/v1/auth/register', [ 'name' => 'John Doe', 'password' => 'password', 'password_confirmation' => 'wrong_password', ]); $response->assertStatus(422); $this->assertDatabaseMissing('users', [ 'name' => 'John Doe', ]);}
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', ]); $response->assertStatus(202) ->assertJsonStructure(['name', 'email']) ->assertJsonCount(2) ->assertJsonFragment(['name' => 'John Updated']); $this->assertDatabaseHas('users', [ 'name' => 'John Updated', ]); } 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:
- The line
$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. - I decided to not create a separate
ParkingFactory
for testing, instead creating the parking records directly with JSON requests.
I think there is an error with one of the tests - I think the assertDatabaseCount should be set to
0
Would it be better to use
timestamp
rather thandatetime
for the MySQL column type for thestart_time
andstop_time
columns? Some tests fail for me due to the expected start_time value including the timezone so it doesn't match up exactly eg..Gavin, in my case i use the format('Y-m-d\TH:i:s.u\Z') method of Carbon to match the start_time. see my comment below.
on your first comment, i agree the code ->assertDatabaseCount('vehicles', 1) should be set to zero.
Gavin, yes, you're probably right, the assertDatabaseCount() is incorrect, will fix in the article (I I'm so glad for text-form courses instead of video...)
As for datetimes, not sure, I haven't encountered this error, maybe you didn't use the
$casts
for those?I added the
$casts
but i'll cross reference my controllers with your completed github repo to double check it :)Last one I promise :)
I didn't seem to get all 3 tests running - they only seemed to all work when I made the following changes to the
ParkingResource.php
- can you confirm if I have done something wrong? I basically used a check to see if it was an instance of Carbon and format the date toY-m-d H:i:s
.I pushed the repo here if that helps https://github.com/gkimpson/car-parking-api
Weird with those datetimes, as Andy commented elsewhere, and I commented above, I didn't encounter the same errors during my test. Not sure whether it's something about my timezone or something else.
Anyway, your way of solving it is one of the ways, yes.
No worries - I am glad my solution works, you should probably put in a few gotchas like these for every tutorial just to keep me on my toes lol :)
Lol good perspective :)
under testUserCanStopParking(), i have to edit my code from this:
to this:
otherwise, it returns an error because using toDateTimeString() on start_time and stop_time does not return fractional seconds ('.000000Z'). so to fix this, i used format('Y-m-d\TH:i:s.u\Z').
Thanks for being so active in the comments, Andy!
youre welcome Povilas. just trying to help people out whenever i can.
Thanks Andy I like the solution that is brilliant thank you again!
I also did the 'homework' tasks - so anybody that hasn't done this yet and wants to do so without any tips please look away now :)
Povilas - would the tests below appear to look like valid tests for you, if not how would you modify them to make them better?
ProfileTest.php
In the second case, I would probably not do the logout, just trying the request without a user. Also, not sure if
assertGuest()
andassertStatus(401)
aren't testing the same thing, don't remember what's actually insideassertGuest()
, check the docs please :)But overall good job!
Thanks for the feedback slowly getting to understand how testing works so this is great! Really love this text series by the way!!
the correct string in my case (L10) ....
Hello, There is a problem in the
UserCanDeleteTheirVehicle
test because thismodel
usesSoftDeletes
, so the record is not deleted, that way, when you doassertDatabaseCount('vehicles', 0)
, it displays the error, because the count of the table is 1 and the comparative is 0. In thecontroller
I switched to usingforceDelete
and then the test passed. Did I make a mistake, or did I misunderstand something?You understood it exactly right, now fixed in the lesson. The reason was that SoftDeletes were added to that model later, so there was a mismatch between repo and lesson text. Now should be good.
Good catch!
Thank you!