How to Unit Test a Laravel API with the Pest Framework

February 18, 2021
Written by
Dotun Jolaoso
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Diane Phan
Twilion

This post was written by a third-party contributor as a part of our Twilio Voices program. It was originally written for Laravel 8 and PHP 7.3, but has been updated to use Laravel 12 and PHP 8.4. It is no longer actively maintained. Please be aware that some information may be outdated. 

Pest is a new PHP testing framework developed by Nuno Maduro. While Pest itself is built on top of PHPUnit, the popular and widely adopted PHP testing framework, Pest aims to provide a better experience for writing tests. The philosophy is simple. Keep the TDD experience simple and elegant by providing expressive interfaces.

In this tutorial, we’ll be looking at how to get started using Pest in a Laravel project. Using the test-driven approach, we’ll be building a simple Todo application that allows you to create, edit, update and delete tasks.

Technical requirements

  • PHP version 8.3 or higher (ideally 8.4)
  • Laravel 12
  • Composer installed globally
  • A basic understanding of automated software testing, such as TDD (Test-driven Development)

Set up Laravel

There are different ways to set up a new Laravel project. You can do so via the Laravel installer or by using Composer. For the sake of this tutorial, we’ll be using Composer.

Run the following commands in your terminal to create a new Laravel project and to change into the new project directory:

composer create-project --prefer-dist laravel/laravel pest-todo
cd pest-todo

Install Pest

Now that we’ve set up a new Laravel project, there are a couple of additional steps we need to carry out to set up Pest with Laravel. First, remove PHPUnit and install Pest by running the following commands:

composer remove phpunit/phpunit
composer require --dev --with-all-dependencies pestphp/pest

Next, we’ll be installing the Pest Plugin for Laravel. To do that, run the following command:

composer require --dev pestphp/pest-plugin-laravel

Then, once the plugin has been installed, run the following command to initialize Pest with the project. This command will create a configuration file named Pest.php in the project's top-level directory:

./vendor/bin/pest --init

The Pest.php file is autoloaded automatically and serves as an ideal place to recursively bind helper classes and traits to your tests.

Laravel comes bundled with some example test files based on PHPUnit. Let’s change those tests to make use of Pest instead. Head over to the tests/Feature directory and take a look at the ExampleTest.php file. Here’s what it currently looks like:

<?php

namespace Tests\Feature;

use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testBasicTest()
    {
        $response = $this->get('/');
        $response->assertStatus(200);
    }
}

To migrate this test to the corresponding Pest implementation, replace the content of the file with the following code:

<?php

it('has welcome page')
    ->get('/')
    ->assertStatus(200);

Much cleaner, right? We’ve reduced this ExampleTest file from about 20 lines of code to just two, while testing the exact same thing and producing the same result. That is, it visits the root URL at ‘/’ and asserts that an HTTP status code of 200 OK is returned.

Pest provides two functions for writing tests — test() and it(). Both functions accept a test description as the first argument and a closure that contains the test expectations as the second argument. They share the same syntax and behavior and you’re free to use either one you find fitting. I personally prefer using it() since they make your test cases read like a complete sentence.

Similarly, let’s make the ExampleTest.php file located in the tests/Unit directory use Pest as well. Replace the contents of the file with the following code:

<?php

test('basic')->assertTrue(true);

Next, use the following command to run the test suite:

./vendor/bin/pest

All tests should be passing as seen in the image below.

Terminal showing pest-todo test results with 2 passed assertions in 0.17s.

Create the Todo model, migration and controller

Our application is going to have a single model called "Todo". Laravel provides a handy command for generating a model, migration, and controller for an entity all at once. To do that run the following command:

php artisan make:model Todo -m -c

The command created three files:

  • migration file named [TODAYSDATE]_create_todos_table.php in database/migrations
  • A model file named Todo.php in app/Models
  • A controller file named TodoController.php in app/Http/Controllers

Next, update the migration file's up() method to match the following code:

public function up(): void
{
    Schema::create('todos', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->boolean('completed')->default(false);
        $table->timestamps();
    });
}

Each todo task will have a name attribute as well as a boolean completed attribute, with a default value of false.

Next, edit the App/Models/Todo.php file with the following code:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Todo extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'completed'];
}

Here, we assign the name and completed attribute of the model to be mass-assignable.

Create the Todo factory

Laravel model factories provide a convenient way of seeding the database with data. This is very useful when it comes to testing.

Run the following command to create a factory class for the Todo model:

php artisan make:factory TodoFactory

This creates TodoFactory.php for us in the database/factories directory.

Now, edit the definition() method within the file to return an array similar to the one below:

return [
    'name'      => 'Deploy Twilio Verify to Live',
    'completed' => false
];

The definition() method returns the default set of attribute values that should be applied when creating a model using the factory.

With all of the Todo model setup finished, you next need to run the database migrations to update the database, by running the command below.

php artisan migrate

Write the tests

Now that we’ve done all the necessary setup, we can get started with writing the tests. These tests are required to have a functioning application and will provide the corresponding implementation to make sure all the tests pass.

Run the following Pest command to create a unit test file:

php artisan pest:test TodoTest --unit

This will create TodoTest.php in the tests/Unit directory. Replace the file’s code with the following code:

<?php

use App\Models\Todo;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(Tests\TestCase::class, RefreshDatabase::class);

it('does not create a todo without a name field', function () {
    $response = $this->postJson('/api/todos', []);
    $response->assertStatus(422);
});

it('can create a todo', function () {
    $attributes = Todo::factory()->raw();
    $response = $this->postJson('/api/todos', $attributes);
    $response->assertStatus(201)->assertJson(['message' => 'Todo has been created']);
    $this->assertDatabaseHas('todos', $attributes);
});

it('can fetch a todo', function () {
    $todo = Todo::factory()->create();

    $response = $this->getJson("/api/todos/{$todo->id}");

    $data = [
        'message' => 'Retrieved Todo',
        'todo' => [
            'id' => $todo->id,
            'name' => $todo->name,
            'completed' => $todo->completed,
        ]
    ];

    $response->assertStatus(200)->assertJson($data);
});

it('can update a todo', function () {
    $todo = Todo::factory()->create();
    $updatedTodo = ['name' => 'Updated Todo'];
    $response = $this->putJson("/api/todos/{$todo->id}", $updatedTodo);
    $response->assertStatus(200)->assertJson(['message' => 'Todo has been updated']);
    $this->assertDatabaseHas('todos', $updatedTodo);
});

it('can delete a todo', function () {
    $todo = Todo::factory()->create();
    $response = $this->deleteJson("/api/todos/{$todo->id}");
    $response->assertStatus(200)->assertJson(['message' => 'Todo has been deleted']);
    $this->assertCount(0, Todo::all());
});

At the top of the file, the uses() method binds the TestCase class and the RefreshDatabase trait to the current test file. The base TestCase class is provided by Laravel and provides helper methods for working with the framework while testing. The RefreshDatabase trait takes care of migrating and resetting the database after each test so that data from a previous test does not interfere with subsequent tests.

Now, let’s go over what each test is doing:

  • it("does not create a todo without a name field"): Laravel provides several helpers for testing JSON APIs and their responses. Here we make use of the postJson() helper to make a POST request to the api/todos endpoint passing in an empty array. Next, the assertStatus() method on the returned response ensures that an HTTP status code of 422 Unprocessable Content should be returned. This test ensures that a name field will always be present on the request payload.
  • it("can create a todo"): This test ensures that a todo is created on making a POST request to the api/todos endpoint. We assert that an HTTP status code of 201 Created is returned and that the database actually contains the todo using the assertDatabase() method.
  • it("can fetch a todo"): This test checks to see that a particular todo task can be fetched using the ID. Using the create() method on the Todo Factory, a todo task is created and stored in the database. Similarly, we assert that the status code returned is 200 OK. The assertJson() method converts the response to an array and verifies that the given array exists within the JSON response that will be returned by the application.
  • it("can update a todo"): This test ensures that a todo task can be updated and that the updated task can be found in the database.
  • it("can delete a todo"): This test ensures that a todo task can be deleted and verifies that the total number of tasks contained within the database is zero.

To run the test suite, run the following command:

./vendor/bin/pest --filter TodoTest

The test suite should be failing since we have not implemented any of the features, as you can see in the screenshot below.

 

Terminal displaying SQL error with failing PHPUnit test and call stack trace.

Build the Todo application

Let’s provide the corresponding implementation of the tests we’ve written so far. Head over to the TodoController.php file in the app/Http/Controllers directory and replace the file’s code with the following code:

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;

class TodoController extends Controller
{
    public function create(Request $request)
    {
        $request->validate($this->rules());

        $todo = Todo::create($request->only(['name']));

        $data = [
            'message' => 'Todo has been created',
            'todo' => $this->mapTodoResponse($todo)
        ];

        return response()->json($data, 201);
    }

    public function show(Todo $todo)
    {
        $data = [
            'message' => 'Retrieved Todo',
            'todo' => $this->mapTodoResponse($todo)
        ];

        return response()->json($data);
    }

    public function update(Todo $todo, Request $request)
    {
        $request->validate($this->rules());

        $todo->update($request->only(['name']));
        $todo->refresh();

        $data = [
            'message' => 'Todo has been updated',
            'todo' => $this->mapTodoResponse($todo)
        ];

        return response()->json($data);
    }

    public function delete(Todo $todo)
    {
        $todo->delete();

        $data = [
            'message' => 'Todo has been deleted'
        ];

        return response()->json($data);
    }

    protected function rules()
    {
        return [
            'name' => 'required|string|min:4'
        ];
    }

    protected function mapTodoResponse($todo)
    {
        return [
            'id' => $todo->id,
            'name' => $todo->name,
            'completed' => $todo->completed
        ];
    }
}
  • The create() method creates a new todo task
  • The show() method returns a given task based on it’s ID
  • The update() method updates a todo task
  • The delete() method deletes a given todo task

Next, we need to add API routes. But, before we can do that, we need to create the API routes file (routes/api.php). To do that, run the following command.

php artisan install:api

During the installation, you'll be prompted as follows:

One new database migration has been published. Would you like to run all pending database migrations? (yes/no) [yes]:

Answer this with either "yes" or just press Enter.

Now, with the API routes file created (routes/api.php), update the file to match the code below:

<?php

use Illuminate\Support\Facades\Route;

Route::get('/todos/{todo}', 'App\Http\Controllers\TodoController@show');
Route::post('/todos', 'App\Http\Controllers\TodoController@create');
Route::put('/todos/{todo}', 'App\Http\Controllers\TodoController@update');
Route::delete('/todos/{todo}', 'App\Http\Controllers\TodoController@delete');

Now that we’ve provided all the corresponding implementations for the tests, we can go back to running our tests and they should all be passing now. Run the test suite again with the following command:

./vendor/bin/pest --filter TodoTest

The terminal output should match the output in the screenshot below.

Terminal displaying results of TodoTest unit tests with 5 tests run and all passing in 0.31 seconds.

That's how to unit test a Laravel API with the Pest framework

In this tutorial, we’ve seen how to go about writing unit tests for a Laravel application using the Pest testing framework. This tutorial can serve as a great guide for getting started with Pest as well as unit testing a Laravel application.

Dotun Jolaoso can be found at https://dotunj.dev/,  https://github.com/Dotunj, and https://twitter.com/Dotunj_.