Jump to content

Looking to start your next project, or have an idea you want to explore? Give us a call on 0121 296 8586 or click below.

Get in touch with us

Rethinking PHP testing: an Intro to PEST.

It's no secret that we're big advocates of Test Driven Development at Jump24, especially as we strive to deliver high quality code. Our test environments run with PHPUnit, which ships with a standard skeleton directory structure in Laravel. Testing in this way has remained largely unchanged for quite some time - Sebastian Bergman's excellent work to create and maintain the PHPUnit framework has ensured that. We've had some nice recent enhancements such as parallel testing, but the way that we write tests in PHP has remained much the same.

Setting the scene: classic PHP testing

Here’s an example of a fairly standard test:

tests/Unit/Services/CharactersTest.php

use App\Services\Characters;
use Cache;
use Tests\TestCase;

class CharactersTest extends TestCase

public function test_will_return_single_character(): void
{
    $characters = $this->app->make(Characters::class);

    $character = $characters->getCharacter('1009220');

    $this->assertEquals('Captain America', $character['name']);
}

OK, so what’s going on here? Well, we’ve got a snake case test written as a function; we use snake case rather than camel case as our test names tend to get quite… _wordy_ (we like writing *lots* of tests to cover as much of the codebase as possible!). In the default installation of PHPUnit, it looks for functions starting with test in the tests folder – you can use annotations to be more explicit though. In order to pull in the assertions, we need the PHPUnit base class – in Laravel the abstract TestCase class extends off this. The other important thing to note here is the line

$characters = $this->app->make(Characters::class);

works, because the TestCase bootstraps the Laravel framework up.

Diving deeper into PHPUnit

Let’s take a look at a more complex example of using PHPUnit:

use RefreshDatabase;

public function test_authorised_user_can_view_empty_dashboard(): void
{
    $user = User::factory()->create();

    $merchant = Merchant::factory()->create();

    $user->merchants()->attach($merchant->id);

    $this->actingAs($user);

    $response = $this->get(route('admin.dashboard'));
    $response->assertStatus(Response::HTTP_OK);
    $response->assertSeeText('Dashboard');
    $response->assertDontSee('My Orders');
}

So, a few more PHPUnit assertions being used here, some imports and a factory method to create a fixture. It’s pretty common to have a series of tests with a common subject to test such as a fixture, so one way of doing this would be to use PHPUnit’s setUp() method:

protected function setUp(): void
{
    $this->user = User::factory()->make();
    parent::setUp();
}

We can then access the user property whenever we need in this file of tests. Also the trait being used is important: use RefreshDatabase; means that Laravel will clear the test database between every test. It’s worth noting I’ve changed the fixture to use make() rather than create() for this reason – let’s assume we want to test methods on the model, for example.

The point here is that it all looks very much like standard PHP, and it’s meant to – it makes PHPUnit code easy to read.

And now …Javascript

Our testing at Jump24 doesn’t stop with the backend in PHP. We use the Jest library for unit testing on the frontend (with Cypress for integration testing). Let’s have a look at an example test in Jest, run on a React component:

describe('', () => {
  test('Will display message', async () => {
    const { getByText } = render(type='success'>Test Message)
    const message = getByText(/Test Message/)
    expect(message).toBeInTheDocument()
  })
  test('Will use amber background when warning', async () => {
    const { container } = render(type='warning'>Test Message)
    expect(container.getElementsByClassName('bg-amber-600').length).toBe(1)
  })
})

As you can see, it’s more than just the syntax that is different here – it’s the approach. Javascript naturally gravitates towards closures and more fluid, functional programming. While PHPUnit has introduced parallel tests to speed up test suite runtime execution, the use of async methods in Javascript enables the tests to be truly non-blocking. The advange of the expressive syntax has to be the biggest difference, with assertions reading almost like TDD stories:

test('Example date test', () => {
  function date () {
    return Date.now()
  }
  // nice and expressive, no?
  expect(foo()).not.toBe(99999)
})

How about this…. in PHP?

The Laravel framework has been very gradually introducing more functional coding styles to PHP over the past few years. The way the framework makes use of callbacks and especially how it’s ORM, Eloquent, implements closures within queries is very expressive in a similar fashion to Javascript. Laravel itself styles itself as a web application framework with expressive, elegant syntax, so how about those PHPUnit tests?

There hasn’t really been the equivalent in PHP, until Nuno Madro from the core Laravel team annouced PEST, which takes inspiration from Jest (get it??!!) Let’s look to refactor our PHP test from earlier from one to another.

Basic Jest vs. PEST comparison

Just to start off, we’ll look at the very basic syntax to show the influence on the language, from Javascript to PHP. Firstly, an empty Jest test:

truthtests.js

describe('the truth tests', () => {
  test('this is true', async () => {
    expect(true).toBeTruthy()
  })

  test('this is false', async () => {
    expect(false).toBeFalsy()
  })
})

And now in PEST:

test('is true', fn () => {
    expect(true)->toBeTrue();
})


test('is false', fn () => {
    expect(false)->toBeFalse();
})

Pretty close, huh? I mentioned BDD – the syntax even allows us to make it look a bit more like BDD:

is('true', fn () => {
    expect(true)->toBeTrue();
})

false('false', fn () => {
    expect(false)->toBeFalse();
})

Enhanced CLI feedback & Coverage

We’ve evolved how we fire tests in PHPUnit over time at Jump24. Originally, we’d add a composer script to fire ./vendor/bin/phpunit –testdox, with a phpunit.xml configuration file read in. Currently we’ve moved to Laravel’s artisan command, with Laravel handling as much of the config as possible (we found test database credentials conflicting between Laravel and phpunit.xml, which was a hard clash to debug!).

PEST’s test runner, just like the artisan command php artisan test comes with the JUnit inspired testdox out of the box.

Real Example: Converting from PHPUnit to PEST

So, we decided at Jump24 to start porting our tests. How can we do this quickly without need for a massive rewrite, you ask? Well, that can be answered with one of the most useful implementation details about PEST: Pest uses PHPUnit under the hood, so your PHPUnit tests AND PEST tests can both be run. Pretty neat, right? No need for that massive dreaded pull request, just migrate them one at a time.

Here’s an example of porting a test with common usages to PEST. Firstly, here’s the PHPUnit test:

class RoleTest extends TestCase {

use RefreshDatabase;

/**
 * @dataProvider roleDataProvider
 */
public function canViewRoleWhenAuthorized($role): void
{
    Role::create([
        'name' => $role,
    ]);

    $user = User::factory()->create();
    $user->assignRole($role);

    $response = $this->actingAs($user)
        ->get(route('dashboard'))

    $expected ? $response->assertOk() : $response->assertUnauthorized();
}

public function roleDataProvider(): array
{
    return [
        'Admin' => ['admin', true],
        'User' => ['user', false],
        'Guest' => ['guest', false],
        'Support' => ['support', true]
    ];
}

}

OK, first things first: we have traditional classed-based names and functions here, plus a PHPUnit data provider (have a look here to see how data providers work if you’re not familiar with them). The test loops through the dataProvider array and asserts the framework response depending on user. Our first step to refactor the test to a PEST-style test.

test('can view role when authorized', function () => {
    Role::create([
        'name' => $role,
    ]);

    $user = User::factory()->create();
    $user->assignRole($role);

    $response = $this->actingAs($user)
        ->get(route('dashboard'))

    $expected ? $response->assertOk() : $response->assertUnauthorized();
})

We’ve ditched the classes and functions, because the PEST runner looks through the test suite for identifiable test functions – i.e. “it()” and “test()”. But what abut the data provider? Well, PEST can use a data provider function string identifier to use, when you define one. You can even use it in a different file. So, our roleDataProvider() is gone, let’s put it back into a file, but using PEST’s API. Under /tests/Datasets/roles.php, we create the following:

 {
    return [
        'Admin' => ['admin', true],
        'User' => ['user', false],
        'Guest' => ['guest', false],
        'Support' => ['support', true]
    ]; 
}

This defines the dataset within PEST regardless of namespaces and directories

(which the PEST runner is already automatically handling for you),

so it’s time to get the test to use it:

test('can view role when authorized', function ($role, $expected) => {
    Role::create([
        'name' => $role,
    ]);

    $user = User::factory()->create();
    $user->assignRole($role);

    $response = $this->actingAs($user)
        ->get(route('dashboard'))

    $expected ? $response->assertOk() : $response->assertUnauthorized();
})->with('roles);

Wait, that’s it? Yes! I did say the PEST runner would take care of naming for you!

Our last bit of PEST doctoring can remove common code out of the test file. In our current setups, we extend off TestCase, which typically has the RefreshDatabase trait – so every single test wipes the test database and reruns the migrations before they are run. We’ve specified in our original PHPUnit style test that we need this trait, so we need to do this in a “PEST way”.

Pest has a base file before running, aptly named tests/pest.php. Currently it looks like so:

in('Feature');

PEST uses it’s API to bind traits like this into runtime. If we’re going to use the RefreshDatabase trait, we can include it in here like so:

in('Feature');

And there we go: an existing test looking cleaner in a PEST way.

Diving deeper into PEST: resources

So, this article serves only as a beginner’s introduction to the framework. I expect many of you might ask “but what about my app that has Inertia/complex service handlers/events/Livewire? etc”. Well, Team PEST have got you covered! Laravel core team member Nuno Maduro has introducted the PEST Meetup online, which you can check out free here. There’s talk of a PEST conference too, so keep your eyes peeled and happy testing!

Laravel Partner

We believe in giving back to the community and are the proud sponsors and organisers of the local PHP Meetup – BrumPHP. We champion Laravel and thought the next logical step was to show support by becoming a partner…so we did.