PEST testing my CRUD gallery

PEST testing my CRUD gallery

I've been using Unit testing for a while now, on and off, and if I'm being totally honest, mainly off. But testing has been a topic of interest ever since I got back into coding. My very first blog post was even about my unconventional Laravel Learning method, seeing me create tests for my code before creating the code.

But as I develop my Laravel skills, I'm building on themes like AdminTW. I've been coming across examples of another type of testing known as PEST.

Why test with PEST?

Pest testing with Laravel is a great choice for developers looking to simplify and streamline their testing process. PEST offers a more intuitive and readable syntax compared to traditional PHPUnit testing.

💡
EDIT: some of the simplifications and streamlines mentioned above have not been put into practice in the tests I'm demonstrating here, but I plan to write an update that utilises some options available with PEST testing.

Additionally, PEST integrates seamlessly with Laravel's test suite, making it easy to incorporate into your existing codebase.

Benefits of using PEST:

1. Intuitive Syntax

2. Faster Test Development

3. Improved Code Coverage

4.Fewer requirements for boilerplates

Also, I will be able to use the Expectations API, which my mentor David Carr is keen for me to be able to use - where it makes sense. But first, i need to learn how to get started with PEST.

A portfolio project I'm currently working on is an image gallery, as this is giving me practice in working with files with Laravel.

It's pretty basic with a front end and a back end, the front end allows me to view image galleries organised by albums and with tags applied to images. The backend is where these albums are managed.

Here I will go over how I have applied PEST testing to the project.

Setup

From the installation phase, I need to tell the project to install PEST while installing a new instance of Laravel.

I use the following command in my terminal at the point of setup.

laravel new TestProject --breeze --pest

Structure

It's not essential, but I've taken the advice to replicate the folder structure of my controllers in my test folder.

Controller Structure

Test Structure

Test Case File

In this file, I am using the Faker function and, therefore, needed to add a use statement for Faker.

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
    use WithFaker;
}

PEST File

There are 2 key changes I made to the Pest.php file

Changed to the LazilyRefreshDatabase::class and added and authenticate function.

  1. LazilyRefreshDatabase::class

Out of the box the Pest.php file includes RefreshDatabase::class which I have updated to LazilyRefreshDatabase. This feature allows developers to migrate their test database only when it is necessary as often you need to have a clean, known state for your database when running tests.

uses(TestCase::class, LazilyRefreshDatabase::class)->in('Feature');
  1. Global Authentication

As my Gallery app requires log in on some of the tests it's helpful to have a function that automatically passes user log in authentication.

By placing this in the Pest.php the function is available to all PEST tests.

function authenticate(): void
{
    $user = User::factory()->create();
    test()->actingAs($user);
}

By including the following code preceding the test declarations, I can now call the authenticate function in files where login is required to perform tests, and it will run before each test runs to log in automatically.

This code specifically calls the User model factory and creates a simulated user.

beforeEach(function () {
    authenticate();
});
💡
I am using Breeze and, therefore, need to access the user model with App\Models\User;

Test File Structure

The test file below is my Album index test file in it's entirety.

Here, you can see:

  1. Opening of PHP

  2. Use declaration so these tests access the Album Model

  3. Usage of the authenticate function

  4. All tests (in this case, just one) use the 3 A's framework as described in this previous post.

This structure is replicated across my index, create, edit and delete tests.

<?php

use App\Models\Album;

beforeEach(function () {
    authenticate();
});

test('can see albums', function() {

    Album::factory()->count(10)->create();
    $this
         ->get(route('albums.index'))
         ->assertOk()
         ->assertViewIs('admin.albums.index')
         ->assertViewHas('albums');
});

Index Tests

My Index Test file Contains 1 test.

The test purpose is to ensure the index file loads the correct route along with the correct data.

Can See Albums

Arrange - This requires a factory to simulate album records.
Here I call on the Album model Factory method and request 10 records be created.
Act - The test access the index method from the albums route using the get method since I only want to test the ability to retrieve records.
Assert - The test checks the status code is 200; the view is as specified, and the view has been passed the album's data.

test('can see albums', function() {

    Album::factory()->count(10)->create();
    $this
         ->get(route('albums.index'))
         ->assertOk()
         ->assertViewIs('admin.albums.index')
         ->assertViewHas('albums');
});

Create Tests

My Create Test file Contains 4 tests.

The purpose of the test is to check the create correctly loads, stores data, passes validation and handles data that is expected (happy path) and unexpected (unhappy path) appropriately.

Can see create

Arrange - Not needed.
Act - Access create method from albums route.
Assert - Checks receive a 200 status code and load specified view.

test('can see create', function () {
    $this
        ->get(route('albums.create'))
        ->assertOk()
        ->assertViewIs('admin.albums.create');
});

Can create album

Arrange - Assign the factory-generated sentence to a variable and assign an array of factory-generated data to the variable.
Act - Post as $data to store method from albums route as I am simulating the form posting the data like when a new record is created.
Assert - Assert that there are no errors. The page redirects, and the page contains the title record.

test('can create album', function () {

    $title = $this->faker->sentence;

    $data = [
        'title' => $title,
        'description' => $this->faker->sentence,
        'cover_file_path' => UploadedFile::fake()->image('logo.png')
    ];

    $this->post(route('albums.store'), $data)
        ->assertSessionHasNoErrors()
        ->assertRedirect(route('albums.index'));

    $this->assertDatabaseHas('albums', ['title' => $title]);
});

Cannot store album with missing data

This test passes if when it detects errors in the title and file upload.

Arrange - Not needed, as checking errors are triggered in the absence of a data array.
Act - Passes data to store method from albums route. Simulating data from a form.
Assert - Checks that the title field and cover_file_path field generate errors (in the absence of data) in line with the validation.

test('cannot store album with missing data', function () {
    $this->post(route('albums.store'), [])
        ->assertSessionHasErrors([
            'title',
            'cover_file_path'
        ]);
});

Cannot store album without title

This test passes if when it detects errors in the title.

Arrange - Not needed, as checking errors are triggered in the absence of title data.
Act - Passes data to store method from albums route. Simulating data from a form.
Assert - Checks the absence of a title and generates errors.

test('cannot store album without title', function () {
    $this->post(route('albums.store'), [
        'title' => UploadedFile::fake()->image('logo.png')
    ])
        ->assertSessionHasErrors([
            'title'
        ]);

});

Cannot store album without image

This test passes if when it detects errors in the file upload.

Arrange - Not needed, as checking errors are triggered in the absence of cover_file_path data.
Act - post to store method from albums route.
Assert - Check the session has no errors.

test('cannot store album without image', function () {
    $this->post(route('albums.store'), [
        'title' => $this->faker->title
    ])
        ->assertSessionHasErrors([
            'cover_file_path'
        ]);
});

Edit Tests

My Edit Test file Contains 3 tests.

Test here are similar to create but differ in that they update rather than create. Again the tests cater for both the happy and the unhappy paths.

Can see edit page

Arrange - Uses factory to create records for the edit page to post. Store as $album
Act - Get record from edit method in the albums route. Pass as $album.
Assert - Check status code, view is as specified and has the album data.

test('can see edit page', function () {

    $album = Album::factory()->create();

    $this
        ->get(route('albums.edit', $album))
        ->assertOk()
        ->assertViewIs('admin.albums.edit')
        ->assertViewHas('album', $album);
});

Can update a record

Arrange - Use factory to create data for $album, Assign data for $title variable, Assign simulation data for $data array.
Act - Patch to update method on albums route, pass $album variable as well as simulated data.
Assert - Check no errors are present, the page redirects and the database contains $album.

test ('can update a record', function () {
    $album = Album::factory()->create();

    $title = $this->faker->title;

    $data = [
        'title' => $title,
        'description' => $this->faker->sentence,
        'cover_file_path' => UploadedFile::fake()->image('logo.png')
    ];

    $this->patch(route('albums.update', $album), $data)
         ->assertSessionHasNoErrors()
         ->assertRedirect(route('albums.index'));

    $this->assertDatabaseHas($album, ['title' => $title]);
});

Cannot update with invalid data

This test passes when the data passed to the update method is invalid.

Arrange - $alabum and $title variables set up with data for simulation.
Act - Patch to update method on albums route, pass $album variable as well as simulated data. $data array is empty to represent broken functionality.
Assert - Check no errors are present, the page redirects and the database contains $album.

test ('cannot update with invalid data', function () {
    $album = Album::factory()->create();

    $title = 'title of album';

    $data = [];

    $this->patch(route('albums.update', $album), $data)
         ->assertSessionHasErrors([
             'title',
             'cover_file_path'
             // don't need to see these can just leave the array blank the above is just the specific errors
         ]);
    // if there is a session error it won't even get to the next assert due to the validation class

    $this->assertDatabaseMissing($album, ['title' => $title]);
});

Delete Tests

My Delete Test file Contains 1 test.

The purpose of the test in this file is checking records can be deleted.

💡
Notice the inclusion of the authentication function directly inside the test as there are no other test to run it can be placed here at the start and provides the same functionality.

Can delete record

Arrange - use the album factory to set up a delectable test. Assign to a variable named album.
Act - Use the delete method to delete the simulated record.
Assert - Check for redirect status code 302, and that redirect goes to a specified route.
Also, check that the album variable has no records signalling the delete worked.

test ('can delete record', function () {
    authenticate();

    $album = Album::factory()->create();

    $this->delete(route('albums.destroy', $album))
         ->assertRedirect(route('albums.index'));

    $this->assertDatabaseCount($album, 0);
});

Conclusion

The test described here is how I have approached the challenge to PEST test a segment of my project.

I plan to expand and continue PEST testing so I can gain more knowledge on what is testable and how to approach it.