Courses

Build Laravel API for Car Parking App: Step-By-Step

Automated Tests with PHPUnit

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!

Laravel API Testing

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' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
 
$response->assertStatus(201)
->assertJsonStructure([
'access_token',
]);
 
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => '[email protected]',
]);
}
 
public function testUserCannotRegisterWithIncorrectCredentials()
{
$response = $this->postJson('/api/v1/auth/register', [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'wrong_password',
]);
 
$response->assertStatus(422);
 
$this->assertDatabaseMissing('users', [
'name' => 'John Doe',
'email' => '[email protected]',
]);
}

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' => '[email protected]',
]);
 
$response->assertStatus(202)
->assertJsonStructure(['name', 'email'])
->assertJsonCount(2)
->assertJsonFragment(['name' => 'John Updated']);
 
$this->assertDatabaseHas('users', [
'name' => 'John Updated',
'email' => '[email protected]',
]);
}
 
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:

Laravel API Testing

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.
Previous: Price Calculation
avatar

I think there is an error with one of the tests - I think the assertDatabaseCount should be set to 0

    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);
    }
👍 1
avatar

Would it be better to use timestamp rather than datetime for the MySQL column type for the start_time and stop_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..

 at tests/Feature/ParkingTest.php:27
     23▕         ]);
     24▕
     25▕         $response->assertStatus(201)
     26▕             ->assertJsonStructure(['data'])
    27▕             ->assertJson([
     28▕                 'data' => [
     29▕                     'start_time'  => now()->toDateTimeString(),
     30▕                     'stop_time'   => null,
     31▕                     'total_price' => 0,
  --- Expected
  +++ Actual
  @@ @@
       array (
         'reg_number' => 'P748',
       ),
  -    'start_time' => '2023-01-19 19:10:40',
  +    'start_time' => '2023-01-19T19:10:40.068655Z',
       'stop_time' => NULL,
       'total_price' => 0,
       'parking_duration_seconds' => 0,
     ),
   )

  Tests:  1 failed
avatar

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.

avatar

on your first comment, i agree the code ->assertDatabaseCount('vehicles', 1) should be set to zero.

avatar

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?

avatar

I added the $casts but i'll cross reference my controllers with your completed github repo to double check it :)

avatar
You can use Markdown
avatar

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 to Y-m-d H:i:s.

    public function toArray($request)
    {
        $totalPrice = $this->total_price ?? ParkingPriceService::calculatePrice(
            $this->zone_id,
            $this->start_time,
            $this->stop_time
        );

        $startDate = $this->start_time ?? null;
        $stopDate = $this->stop_time ?? null;
        $parkingDurationInSecs = ($stopDate) ? $startDate->diffInSeconds($stopDate) : 0;

        if ($startDate instanceof Carbon) {
            $startDate = $this->start_time->format('Y-m-d H:i:s');
        }

        if ($stopDate instanceof Carbon) {
            $stopDate = $this->stop_time->format('Y-m-d H:i:s');
        }

        return [
            'id' => $this->id,
            'zone' => [
                'name' => $this->zone->name,
                'price_per_hour' => $this->zone->price_per_hour,
            ],
            'vehicle' => [
                'reg_number' => $this->vehicle->reg_number,
            ],
            'start_time' => $startDate,
            'stop_time' => $stopDate,
            'total_price' => $totalPrice,
            'parking_duration_seconds' => $parkingDurationInSecs,
        ];
    }

I pushed the repo here if that helps https://github.com/gkimpson/car-parking-api

👍 1
avatar

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.

avatar

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 :)

avatar

Lol good perspective :)

avatar
You can use Markdown
avatar

under testUserCanStopParking(), i have to edit my code from this:

$response->assertStatus(200)
        ->assertJsonStructure(['data'])
        ->assertJson([
            'data' => [
                'start_time'  => $updatedParking->start_time->toDateTimeString(),
                'stop_time'   => $updatedParking->stop_time->toDateTimeString(),
                'total_price' => $updatedParking->total_price,
            ],
        ]);

to this:

$response->assertStatus(200)
        ->assertJsonStructure(['data'])
        ->assertJson([
            'data' => [
                'start_time'  => $updatedParking->start_time->format('Y-m-d\TH:i:s.u\Z'),
                'stop_time'   => $updatedParking->stop_time->format('Y-m-d\TH:i:s.u\Z'),
                'total_price' => $updatedParking->total_price,
            ],
        ]);
					

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').

🥳 1
avatar

Thanks for being so active in the comments, Andy!

avatar

youre welcome Povilas. just trying to help people out whenever i can.

avatar

Thanks Andy I like the solution that is brilliant thank you again!

avatar
You can use Markdown
avatar

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

    public function testUserCannotUpdatePasswordWithNonMatchingPassword()
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->putJson('/api/v1/password', [
            'current_password' => 'password',
            'password' => 'testing123',
            'password_confirmation' => 'incorrectpassword',
        ]);

        $response
            ->assertStatus(422)
            ->assertJson([
                'message' => 'The password confirmation does not match.',
            ]);
    }

    public function testUnauthenticatedUserCannotAccessProfile()
    {
        $user = User::factory()->create();
        $response = $this->actingAs($user);
        Auth::logout();
        $response = $this->getJson('/api/v1/profile');
				$this->assertGuest();

        $response
            ->assertStatus(401)
            ->assertJson([
                'message' => 'Unauthenticated.',
            ]);
    }
👍 3
avatar

In the second case, I would probably not do the logout, just trying the request without a user. Also, not sure if assertGuest() and assertStatus(401) aren't testing the same thing, don't remember what's actually inside assertGuest(), check the docs please :)

But overall good job!

avatar

Thanks for the feedback slowly getting to understand how testing works so this is great! Really love this text series by the way!!

avatar

the correct string in my case (L10) ....

[message' => 'The password field confirmation does not match.',]

avatar
You can use Markdown
avatar

Hello, There is a problem in the UserCanDeleteTheirVehicle test because this model uses SoftDeletes, so the record is not deleted, that way, when you do assertDatabaseCount('vehicles', 0), it displays the error, because the count of the table is 1 and the comparative is 0. In the controller I switched to using forceDelete and then the test passed. Did I make a mistake, or did I misunderstand something?

👍 1
avatar

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!

avatar
You can use Markdown
avatar
You can use Markdown