Elegant Tests With Laravel and Pest

In this blog post, you’ll learn interesting and highly practical insights about the Pest PHP testing framework, alongside convenient examples on how to use Pest in combination with the Laravel PHP application framework.

Pest framework is a modern testing framework for PHP applications that provides a more readable and expressive syntax for writing tests. It was created by Nuno Maduro, a well-known PHP contributor.

Using the Pest framework, developers can write tests that are easy to read, understand, and maintain. It offers a clean and elegant syntax that allows developers to focus on writing tests that accurately reflect the behavior of their application, rather than getting bogged down in boilerplate code or complex testing syntax.

It is important to mention that the Pest framework is built as an abstraction layer upon PHPUnit and these two testing frameworks are completely interoperable. What this means in practice is that developers can use functionalities of both frameworks to their liking, either in the same or separate test cases on their projects.

One handy feature that Pest includes is the possibility of using PHP’s anonymous functions and closures to create a more readable and expressive syntax. This allows developers to write tests that read like natural language, making it easier to understand what the test is doing and what it is testing.

The Pest framework also offers a number of advanced features, such as test doubles, dependency injection, and parallel testing, that can help developers write more effective tests with less code.

Notable features

Expectations

Pest uses the expectation API which provides a powerful and expressive way to make assertions in your tests using the expect() method. It comes packed with a wide range of common assertion methods that cover various testing scenarios. These assertions include checking for equality (toBe(), toEqual()), asserting the presence or absence of elements (toContain(), toBeEmpty()), comparing values (toBeGreaterThan()toBeLessThan()), and more. The expectation API also allows method chaining and negation. You can chain multiple methods together to create more advanced assertions, improving the expressiveness of your tests. Assertions can be easily negated by placing the not modifier before any expectation method.

<?php
test('calculation', function () {
    $result = 2 * 4;
    expect($result)
        ->toBeInt() // Assert that the result is an integer 
        ->toBe(8) // Assert that the result value is 8 
        ->not->toBeJson() // Using "not" modifier, asserting that the value isn't JSON 
        ->not->toBe(5); // Another usage example of "not" modifier
});

Hooks

Pest provides hooks that allow you to define actions that will run before and after your tests. Hooks help you prepare the necessary conditions for your tests and perform cleanup operations when they are completed. The beforeEach() hook can be used to define actions that run before each test case, ensuring a clean and consistent state for each test. The afterEach() hook allows you to define actions that run after each test case, such as resetting variables or cleaning up resources. There are also beforeAll() and afterAll() “global” hooks, which run once before and after all tests inside a single test file. These hooks are useful for tasks like setting up and tearing down databases or other expensive operations.

<?php
beforeAll(function () {
    // Executes first, before any of tests are run
    // Eg. can be used to set up a testing database
});
beforeEach(function () {
    $this->bookRepository = new BookRepository();
});
it('creates books', function () {
    $book = $this->bookRepository->create();
     expect($book)->toBeInstanceOf(Book::class);
});
afterEach(function () {
    $this->bookRepository->reset();
});
afterAll(function () {
    // Executes last, after all tests are done
    // NOTE: $this object CAN NOT be used inside beforeAll() and afterAll() methods  
});

Grouping & Filtering

Pest has various filtering options, allowing you to selectively run tests or groups of tests. Tests can be filtered based on their names, groups, or other specified criteria.

There are several command-line options, for example, you can use the –filter option to run tests matching a particular pattern or expression, the –dirty option will only run tests that are uncommitted according to Git, the –bail option will stop Pest execution on first encountered error. More of these options can be found in the official documentation.

Pest also allows you to group your tests together for better organization and readability. This can be done by using the group() function and providing a descriptive name for the group. It helps organize tests logically based on features, or any other criteria that make sense for your application. After assigning a group, you can use the –group option to run that specific test group.

Optimization

When testing with Pest, there are optimization techniques which can be used to make tests more efficient. One of them is parallel testing, which allows Pest to run multiple tests inside multiple separate processes. This can speed up the testing dramatically. You can run tests in parallel with the following command:

vendor/bin/pest –parallel –processes=4

Another noteworthy optimization technique that can be used with Pest is profiling.
This comes in handy when there are a large number of tests in a project that don’t run efficiently. Usually they will cause slowdowns during test execution. Profiling can be done using the following command:

vendor/bin/pest –profile

This will generate a detailed report that highlights all tests that take the largest amount of time to execute. Those can then be optimized to be less resource intensive.

Mocking

Pest integrates seamlessly the with popular PHP mocking frameworks (eg. Mockery, Prophecy). According to Pest’s documentation, Mockery is the preferred framework for using test doubles. You can use Pest’s mocking methods in conjunction with Mockery to create mock objects and define their behaviors. This allows you to test the functionality of specific classes, without invoking class dependencies, simply by using object mocks for those dependencies. By using Mockery, Pest allows you to make method expectations with shouldReceive() function, argument expectations by utilizing the with() function, and return value expectations with andReturn() method. Additional mocking methods, techniques and best practices can be found in Mockery’s official documentation.

<?php
test('user authorization', function () {
    $auth = Mockery::mock(Auth::class);
    $auth->shouldReceive('login');
    $auth->shouldReceive('authorize');
    $user = new UserRepository($auth);
    expect($user->getAuthorized())
        ->not->toBeEmpty();
    // Invocations for 'login' and 'authorize' have been mocked, the 'getAuthorized' method should work
});

Coverage

Pest includes code coverage reporting options, allowing you to measure the extent to which your tests evaluate your codebase. To achieve this, Pest uses PHPUnit’s built-in code coverage capabilities (default configuration is stored in phpunit.xml file). Additional installation of XDebug 3.0+ or PCOV is necessary to generate a code coverage report. Code coverage analysis helps identify areas of your code that are not adequately covered by tests. This way you can identify untested code paths, improve the quality of your tests, and ensure better overall test coverage. To make a coverage report use the following command:

vendor/bin/pest –coverage –min=80

Note that the command above also has the –min option. Pest also allows you to enforce a minimum threshold for your coverage. In case the coverage results are not greater than the specified threshold value, the coverage report will fail.

Installation

In the following steps we will set up a test project using Laravel PHP framework. Follow these instructions to install Pest framework in the Laravel project:

1. First, make sure you have a Laravel application set up and running. If you don’t have one already, you can create a new Laravel project using the following command in your terminal:

composer create-project –prefer-dist laravel/laravel laravel-pest

2. Once you have Laravel ready, you can install the Pest framework using Composer. In your terminal, navigate to the root directory of your Laravel project and run the following command:

composer require pestphp/pest –dev –with-all-dependencies

This command will install Pest framework and all of its dependencies in your application’s vendor directory.

3. Once Pest framework is installed, you can initialize it using the pest command-line tool. From the application’s root directory, using the terminal, run the following command:

vendor/bin/pest –init

This command will create a new file named Pest.php in the tests directory, which can be used to configure and fine tune your testing framework settings.

4. To run your tests, you can use the following command:

vendor/bin/pest

This will run all of your Pest tests located in the tests directory.

Output of the command above should look similar to this.

And voilà! You now have Pest framework installed and can start writing tests for your application. If you encounter any issues during the installation process, be sure to check out the framework documentation or reach out to the Pest community for help.

Writing tests

In this section, we’ll set up a small demo project. We’ll make unit tests for a repository class and write a feature test (some call them functional tests) for an API endpoint. The test examples that we will write should cover some common use cases of the Pest framework in your Laravel projects.

1. First, we need to make sure our development environment is up and running. In the project’s root directory, run the following command to start up Laravel Sail and the required Docker containers:

vendor/bin/sail up -d

2. When the environment is ready, we make a Book model, factory and migration classes (the command vendor/bin/sail artisan will be used, since Laravel version 10 in this demo project is containerized by default):

vendor/bin/sail artisan make:model –migration –factory Book

In the Books migration class that was created, we define the structure of our database table.

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('books', static function (Blueprint $table) {
            $table->id();
            $table->string('title', 512);
            $table->string('author', 128);
            $table->string('isbn', 13);
            $table->date('date_published');
            $table->integer('number_sold');
        });
    }
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('books');
    }
};

The generated Book model class will help us instantiate objects representing the table entries.

<?php
namespace App\Models;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
 * App\Models\Book
 *
 * @property int $id
 * @property string $title
 * @property string $author
 * @property string $isbn
 * @property DateTimeInterface $date_published
 * @property int $number_sold
 */
class Book extends Model
{
    use HasFactory;
    public $timestamps = false;
}

BookFactory class can be used in combination with the Faker library to generate some test database entries.

<?php
namespace Database\Factories;
use App\Models\Book;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
 * @extends Factory<Book>
 */
class BookFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'title' => ucwords($this->faker->words($this->faker->numberBetween(1, 5), true)),
            'author' => $this->faker->name(),
            'isbn' => $this->faker->isbn13(),
            'number_sold' => $this->faker->numberBetween(100, 1000000),
            'date_published' => $this->faker->dateTime(),
        ];
    }
}

3. After the migration file is prepared, we execute the database migrations:

vendor/bin/sail artisan migrate:fresh

4. Now we will manually create a BookRepository class in the app/Repositories folder. It should handle the database related operations, without cluttering our controller classes.

<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Models\Book;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class BookRepository
{
    public function findBy(array $parameters): Collection
    {
        $query = Book::query();
        foreach ($parameters as $attribute => $value) {
            $query->where($attribute, $value);
        }
        return $query->get();
    }
    public function filterBy(array $parameters): Collection
    {
        $query = Book::query();
        $query->where(function (Builder $subQuery) use ($parameters) {
            foreach ($parameters as $attribute => $value) {
                $subQuery->orWhere($attribute, 'like', '%' . $value . '%');
            }
        });
        return $query->get();
    }
}

5. With the following commands, controllers for the Books API endpoint will be generated. Inside of these controllers we will inject our BookRepository class and invoke the required methods.

vendor/bin/sail artisan make:controller –api –invokable GetBooksController
vendor/bin/sail artisan make:controller –api -i GetBookByIsbnController

The GetBooksController.php and GetBookByIsbnController.php class files should look like this:

<?php
namespace App\Http\Controllers;
use App\Repositories\BookRepository;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
class GetBooksController extends Controller
{
    public function __construct(private readonly BookRepository $bookRepository)
    {
    }
    public function __invoke(Request $request): JsonResponse
    {
        $validParameters = $request->validate([
            'title' => 'string',
            'isbn' => 'string',
            'author' => 'string',
        ]);
        $booksFound = $this->bookRepository->filterBy($validParameters);
        return new JsonResponse($booksFound);
    }
}
<?php
namespace App\Http\Controllers;
use App\Repositories\BookRepository;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
class GetBookByIsbnController extends Controller
{
    public function __construct(private readonly BookRepository $bookRepository)
    {
    }
    public function __invoke(Request $request): JsonResponse
    {
        $bookFound = $this->bookRepository->findBy([
            $parameter = 'isbn' => $request->route()?->parameter($parameter)
        ]);
        return new JsonResponse($bookFound->first());
    }
}

6. After the controllers have been set up, API routes can be made available in routes/api.php file.

<?php
use App\Http\Controllers\GetBookByIsbnController;
use App\Http\Controllers\GetBooksController;
use Illuminate\Support\Facades\Route;
Route::get('/books', GetBooksController::class);
Route::get('/books/{isbn}', GetBookByIsbnController::class)->where('isbn', '[0-9]{10,13}');

7. Before we start writing any tests, we will make some minor adjustments in the Pest.php configuration file. The “Unit” folder must be added to the uses() method, this will allow the TestCase class to be bound to our unit tests.
Also, we need to uncomment the line containing the RefreshDatabase class. This will allow the Pest framework to automatically clear the test data from the database when tests are finished, since all of the tests use Faker to generate dummy data with BookFactory class during testing. These adjustments should in the end look like this:

<?php
uses(
    Tests\TestCase::class,
    Illuminate\Foundation\Testing\RefreshDatabase::class,
)->in('Unit', 'Feature');

8. Unlike PHPUnit, Pest test files don’t require definitions of test classes, you can just call methods directly as you will see in the following examples. The unit and feature test files and folders can be generated with the artisan command as follows:

vendor/bin/sail artisan make:test –unit -p Repositories/BookRepositoryTest
vendor/bin/sail artisan make:test –pest Endpoints/Books/BooksEndpointTest

Now we can edit the generated BookRepositoryTest.php and BooksEndpointTest.php files to add the following code:

[Gist – BookRepositoryTest.php]
[Gist – BooksEndpointTest.php]

9. Last but not least, we can run these tests by executing the following command:

vendor/bin/sail pest –group=books

If everything was done correctly, the output should be successful for all assertions or expectations.

All Pest tests were successful.

Conclusion

Pest framework is a powerful testing tool that can help developers write better tests in less time. Whether you’re new to testing or a seasoned developer, Pest framework is definitely worth checking out if you’re looking for a more modern and expressive way to write tests for Laravel or any other PHP applications.


Leave a Reply

Your email address will not be published. Required fields are marked *