The Workshop: Testing with Pest Framework - php[architect] Magazine August 2021

Joe • February 25, 2022

learning phparch writing php testing

Warning:

This post content may not be current, please double check the official documentation as needed.

This post may also be in an unedited form with grammatical or spelling mistakes, purchase the August 2021 issue from http://phparch.com for the professionally edited version.

Pest is a PHP testing framework focused on simplicity and brings a powerful expectations API to PHP. Pest is influenced by Jest, a JavaScript testing framework. Pest was created by Nuno Maduro originally via Sponsorware license, and ultimately has been published under the MIT open source license. You can think of Pest ha an alternative to other testing frameworks, such as Codeception, which can coexist with your existing PHPUnit tests.

The Pest framework had its first version tagged on May 11, 2020, but don’t let the short lifetime of the project deter you from giving Pest a spin in your project. Spatie, a well known Laravel ecosystem package powerhouse has embraced Pest and overall Pest has gained popularity due to the reduction in common test boilerplate. Whether you’re ready to migrate your entire test suite over to Pest or just want to see what all the hype is about, join us as we take Pest for a spin with our basic Easy Math PHP and then move on to more real-world tests in a Laravel application. The third episode of Pest in Practice features Nuno offering some insights into Pest and is well worth a watch.

Pest is compatible with most PHP projects running PHP 7.3 or higher with some plugins requiring PHP 8, and won’t break your existing PHPunit tests. If you’re using Codeception or Laravel BrowserKit Testing you should also rest assured that Pest is compatible. If you don’t have an existing test suite, now is the perfect time to start building tests with the Pest framework. One interesting reason (and we haven’t even shown off the expectation API) to check out Pest is that it does a decent job of getting out of your way. Often people can be overwhelmed by all of the boilerplate code traditional testing systems required. That complexity compounds when you venture deeper into set up and tear down functionality.

It’s just math

The Easy Math PHP application is a simple library that allows us to easily demonstrate different concepts. It currently has a PHPUnit test suite to ensure our math operations work as expected. We can run ./vendor/bin/phpunit to ensure we’re starting from a place where we know our tests are passing:

Our library has a class to perform addition and another for subtraction. The Addition class is straightforward:

<?php
namespace EasyMath;

class Addition
{

    public function add($x, $y)
    {
            return $x + $y;
    }


    public function sum(float ...$numbers): float
    {
        $sum = 0;

        foreach ($numbers as $number) {
            $sum += $number;
        }

        return $sum;
    }

}

Which the corresponding PHPUnit test:

<?php
namespace EasyMath\Tests;

use EasyMath\Addition;
use PHPUnit\Framework\TestCase;

class AdditionTest extends TestCase
{
    public function testEasyMathKnowsHowToAdd($x, $y, $expected)
    {
        $math = new Addition();

        $this->assertEquals(
            $expected,
                $math->add($x, $y)
        );
    }

    public function testEasyMathKnowsHowToSum($x, $y, $expected)
    {
        $math = new Addition();

        $this->assertEquals(
            $expected,
            $math->sum($x, $y)
        );
    }

    public function mathProvider()
    {
        return [
            [0, 0, 0],
            [0, 1, 1],
            [1, 0, 1],
            [1, 1, 2],
            [1337, 1337, 2674],
        ];
    }
}

Installing Pest

We can install pest by running composer require pestphp/pest --dev --with-all-dependencies:

The power of Pest comes from the Pest Testcase which allows us to use Pest alongside our PHPUnit tests. We need to create our tests/Pest.php file via ./vendor/bin/pest --init:

With Pest installed, we can run our existing test suite with ./vendor/bin/pest:

We can see there is a new Tests\ExampleTest case which was added by Pest:

<?php

test('example', function () {
    expect(true)->toBeTrue();
});

We also have another file, tests/Pest.php, which contains the code Pest requires to bootstrap it’s own functionality such as the expect() and test() methods. This allows Pest to run alongside other frameworks without interfering.

The example test is a closure running the Pest expect() function being passed into a test() method where the string represents the name of the particular test. There’s no class extending a TestCase and there are no use statements. Granted this is an incredibly simple example (we’ll be using use() methods instead of expect() statements) it highly demonstrates the “get out of your way” approach Pest offers. Test assertions are all about checking whether a condition is true or false. There are many different ways we accomplish this by using different assertion functions offered by our testing frameworks which allow us to write explicit tests which are easier for humans to read and interpret.

We will create tests/AdditionPestTest.php as the Pest version of our existing PHPUnit test tests\AdditionTest.php. We also need to create a folder for our data provider. Data Providers in PHPUnit are Datasets in Pest. The Pest documentation suggests using Shared Datasets to keep test code clean instead of copy and pasting large arrays or objects to our expect() calls. PHP Easy Math only has two data sets used in a total of four places, but we should practice how we play so we’ll keep our tests neat by creating tests/Datasets/MathProvider.php which contains an arrow where each element are the arguments to pass to a test with the expected return value as the last element:

<?php

dataset('addition-math', [
    [0, 0, 0],
    [0, 1, 1],
    [1, 0, 1],
    [1, 1, 2],
    [1337, 1337, 2674],
]);

dataset('subtraction-math', [
    [0, 0, 0],
    [0, 1, -1],
    [1, 0, 1],
    [1, 1, 0],
    [2674, 1337, 1337],
]);

Now we can build our tests/AdditionPestTest.php

<?php

use EasyMath\Addition;

test('testEasyMathKnowsHowToAdd', function ($x, $y, $expected) {
    $math = new Addition();
    expect($math->add($x,$y))->toBe($expected);
})->with('addition-math');

test('testEasyMathKnowsHowToSum', function ($x, $y, $expected) {
    $math = new Addition();
    expect($math->sum($x,$y))->toBe((float)$expected);
})->with('addition-math');

Our first test will be to ensure “'testEasyMathKnowsHowToAdd'” to exercise our add() method. We’ll chain ->with('addition-math') at the end of our test closure. Doing sowill provide our data set to the function as we pass them as function ($x, $y, $expected). We'll instantiate a new instance of our class with and then we'll begin stating we expect() the output of $math->sum($x,$y) will toBe() our $expected value cast as a float. We can check our work so far by passing our test's path and filename directly to Pest:

We’ve successfully rebuilt our existing Addition class’ PHPUnit tests with Pest tests and can see them run alongside the rest of test suite:

This ability to coexist gives us the flexibility to slowly port our application’s test suite to Pest or just take it for a test drive without any danger to our application. You can continue writing PHPUnit and Pest tests if desired.

Let’s get real

The attraction to Pest for me is I would like something to replace the less popular (as of late) Laravel Browserkit Test package. In order to really put Pest through its paces, we’ll add it to a web based user management application used to manage members at my local makerspace. The RFID app is an up to date Laravel 8 application using typical user based authentication with a secondary authorization token and pin to gain access to a physical location. Users can authenticate to the web application and update their information and administrators have the ability to edit and update users.

Pest supports Laraval via a plugin so instead of installing Pest diretly,with can install the plugin via composer require pestphp/pest-plugin-laravel --dev, this will also give us an Artisan command to setup Pest instead of running --init as we did with PHP Easy Math: php artisan pest:install. With Pest installed and passing all of our existing tests, we’re ready to start writing tests which. We’re going to create them in a Pest feature folder at tests/Pest to keep them outside of our existing tests/Feature tests.

The first test we’ll recreate will be our user index test located at tests/Feature/UserIndexTest.php. The test creates an admin user from a factory and then acts out a scenario where the user visits a URL and ensures specific data exists on the page. The last assertion we perform is to ensure the HTTP response code was OK or 200 meaning the request was successful. The complete file can be seen:

<?php

use App\Tests\BrowserKitTest;
use Illuminate\Foundation\Testing\DatabaseTransactions;

class UserIndexTest extends BrowserKitTest
{
    use DatabaseTransactions;

    public function testMembersRouteFromAdmin(): void
    {
        $user = \App\User::factory()->create();
        $user->admin = true;
        $user->save();

        $this->actingAs($user)
            ->visit('/users')
            ->see($user->name)
            ->see($user->email)
            ->see($user->created_at)
            ->see($user->paypal_email);

        $this->assertResponseOk();
    }
}

To create our first Pest test case we’ll use the artisan command to place our test in a Pest subfolder of tests/Feature:

php artisan pest:test Pest/PestUserIndexTest.php

Previously we used actingAs($user) to tell Browserkit to perform the actions as that specific user of the application. With Pest weuseand execute thebe($user)before we perform our request. OurtestMembersRouteFromAdmin():` test refactored for Pests ends up as:

<?php
use function Pest\Laravel\be;
use App\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

it('has users index page', function () {
    $user = User::factory()->create();
    $user->admin = true;
    $user->save();

    be($user)->get('/users')
             ->assertStatus(200)
             ->assertSee($user->name)
             ->assertSee($user->email)
             ->assertSee($user->created_at)
             ->assertSee($user->paypal_email);
});

Note: We’re use-ing Illuminate\Foundation\Testing\DatabaseTransactions which tells the framework to reset our database after the tests have been executed. We still need to run uses(DatabaseTransactions::class); to make Pest aware of the Test Trait we want to use.

We can check our work to confirm our new Pest test passes and output is green:

We now have a Pest test verifying that our users index page loads and contains data we expect. We can expand a bit using Pest methods to test that our members API endpoint not only returns data correctly, but that data is JSON and contains the top level keys we expect. We're also going to take the response and use json_decode() to turn it into an associative array so we can ensure the contents and keys of an individual item or member. Our complete Pest test tests/Feature/Pest/PestApiTest.php:

<?php
use function \Pest\Laravel\getJson;
use App\Member;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\WithoutMiddleware;

uses(DatabaseTransactions::class);
uses(WithoutMiddleware::class);

it('members api endpoint returns data', function () {
    $members = Member::factory(5)->create();

    $response = getJson('/api/members');

    // We expect our JSON response to have 2 keys
    expect($response->getContent())->json()->toHaveKeys(
        ['members', 'timestamp']
    );

    // Get our results in an array
    $contents = json_decode($response->getContent(), true);

    expect($contents['members'])->tobeArray()->toBeIterable();

    // Expect a member has specific keys
    expect($contents['members'][0])->toHaveKeys(
            ['id', 'hash', 'key', 'irc_name', 'updated_at', 'created_at']
    );

});

We're adding an additional middleware class to bypass our API credential requirement. We're using Laravel's Passport to handle API authentication which means I'm not worried about testing that specific functionality. The trick to getting an associative array instead of an object from json_decode is to pass true as the optional second parameter. Next we'll pass $contents['members'] which should be an array and thus also iterable. finally, we'll test that one of our member array contains the keys we expect.

Testing More

Mocking is always a complicated test topic and there are several opinions on the practice of mocking objects in your test suite. To leverage mocking in Pest you’ll want to run composer require pestphp/pest-plugin-mock --dev to install the mock plugin. It’s a simplistic API built on top of Mockery. This plugin allows us to easily stub out methods for a OurAwesomeApiService class which has get and post messages.

test('test some service', function () {
    $mock = mock(OurAwesomeApiService::class)->expect(
        get: fn ($name) => false,
        post: fn ($name) => true,
    );

    expect($mock->get('Foo'))->toBeFalse();
    expect($mock->post('Bar'))->toBeTrue();
});

Another feature worth mentioning with Pest is [snapshot testing[(https://sebastiandedeyne.com/a-package-for-snapshot-testing-in-phpunit/) which is the practice of saving the test output into a __snapshots__/ directory the first time the test runs:

Subsequent runs of the test will pass if the data in the snapshot has not changed. If the data has changed, the test fails indicating that something in the snapshot’s dataset has changed and should be investigated.

An example of using snapshot testing with Pest would be if we wanted to guarantee that a static set of API data, such as members we’ve previously used, doesn’t change when we exercise our code via our Pest snapshots. If the data changes we would be alerted to the failure:

Because our test is using the Member::factory() our snapshot will never be the same so this test will always fail. This is a downside if you’re heavily using faker and it might be worthwhile to add some static data sets to your test suite to ensure you’re testing data that matches what you see in production. Always be careful to avoid using production data in local development environments.

If we change our members variable to an empty JSON string: $members = '{}'; we can update our snapshots via ./vendor/bin/pest tests/Feature/Pest/PestApiTest.php -d --update-snapshots and see our tests pass with ./vendor/bin/pest tests/Feature/Pest/PestApiTest.php

Conclusion

We’ve scratched the surface of what Pest can do but more importantly it’s a viable option if you have a lot of legacy tests laying around old projects. Whether you are a die hard PHPUnit verteran or a total testing newbie: Pest is worth your time to check out. I was pleasantly surprised to find it to be a very solid replacement for Laravel Browserkit Testing. Is Pest going to replace PHPUnit for me? I doubt it because I’ve just been using it forever. But it’s not a popularity contest as sometimes it feels. Just over a year ago what would become a very controversial PR was opened to Use Pest as the default testing framework for Laravel 8.x Looking back a year later with hindsight of pretty large adoption of Pest not just within the Laravel user base but other frameworks and projects as well indicate to me that I’m safe to rely on pest for the next four or five years that I’ve relied on Browserkit testing. I wouldn’t be shocked to see Pest as the default testing framework (complete with optional presets for Pest or PHPUnit) for Laravel version 9. I feel fortunate to be a part of the PHP community that has so many different options for testing code.

Warning:

This post content may not be current, please double check the official documentation as needed.

This post may also be in an unedited form with grammatical or spelling mistakes, purchase the August 2021 issue from http://phparch.com for the professionally edited version.