Rethinking PHP testing: an Intro to PEST.
4 minutes
Setting the scene: classic PHP testing
Here's an example of a fairly standard test:
1use App\Services\Characters; 2use Cache; 3use Tests\TestCase; 4 5class CharactersTest extends TestCase 6{ 7 public function test_will_return_single_character(): void 8 { 9 $characters = $this->app->make(Characters::class);10 $character = $characters->getCharacter('1009220');11 $this->assertEquals('Captain America', $character['name']);12 }13}
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
1$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:
1use RefreshDatabase; 2 3public function test_authorised_user_can_view_empty_dashboard(): void 4{ 5 $user = User::factory()->create(); 6 7 $merchant = Merchant::factory()->create(); 8 9 $user->merchants()->attach($merchant->id);10 11 $this->actingAs($user);12 13 $response = $this->get(route('admin.dashboard'));14 $response->assertStatus(Response::HTTP_OK);15 $response->assertSeeText('Dashboard');16 $response->assertDontSee('My Orders');17}
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:
1protected function setUp(): void2{3 $this->user = User::factory()->make();4 parent::setUp();5}
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:
1describe('', () => { 2 test('Will display message', async () => { 3 const { getByText } = render(type='success'>Test Message) 4 const message = getByText(/Test Message/) 5 expect(message).toBeInTheDocument() 6 }) 7 test('Will use amber background when warning', async () => { 8 const { container } = render(type='warning'>Test Message) 9 expect(container.getElementsByClassName('bg-amber-600').length).toBe(1)10 })11})
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:
1test('Example date test', () => {2 function date () {3 return Date.now()4 }5 // nice and expressive, no?6 expect(foo()).not.toBe(99999)7})
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
1describe('the truth tests', () => {2 test('this is true', async () => {3 expect(true).toBeTruthy()4 })5 6 test('this is false', async () => {7 expect(false).toBeFalsy()8 })9})
And now in PEST:
1test('is true', fn () => {2 expect(true)->toBeTrue();3})4 5 6test('is false', fn () => {7 expect(false)->toBeFalse();8})
Pretty close, huh? I mentioned BDD – the syntax even allows us to make it look a bit more like BDD:
1is('true', fn () => {2 expect(true)->toBeTrue();3})4 5false('false', fn () => {6 expect(false)->toBeFalse();7})
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.