Skip to main content

Automated Tests with PHPUnit

Premium
12 min read

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

The Full Lesson is Only for Premium Members

Want to access all of our courses? (31 h 16 min)

You also get:

55 courses
Premium tutorials
Access to repositories
Private Discord
Get Premium for $129/year or $29/month

Already a member? Login here

Comments & Discussion

GK
Gavin Kimpson ✓ Link copied!

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);
    }
GK
Gavin Kimpson ✓ Link copied!

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
A
andywong31 ✓ Link copied!

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.

A
andywong31 ✓ Link copied!

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

PK
Povilas Korop ✓ Link copied!

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?

GK
Gavin Kimpson ✓ Link copied!

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

GK
Gavin Kimpson ✓ Link copied!

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

PK
Povilas Korop ✓ Link copied!

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.

GK
Gavin Kimpson ✓ Link copied!

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

PK
Povilas Korop ✓ Link copied!

Lol good perspective :)

A
andywong31 ✓ Link copied!

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

PK
Povilas Korop ✓ Link copied!

Thanks for being so active in the comments, Andy!

A
andywong31 ✓ Link copied!

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

GK
Gavin Kimpson ✓ Link copied!

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

GK
Gavin Kimpson ✓ Link copied!

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.',
            ]);
    }
PK
Povilas Korop ✓ Link copied!

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!

GK
Gavin Kimpson ✓ Link copied!

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

O
oxicot ✓ Link copied!

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

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

M
Márlon ✓ Link copied!

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?

PK
Povilas Korop ✓ Link copied!

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!

M
Márlon ✓ Link copied!

Thank you!