Access Laravel before and after running Pest tests
Photo by oktavianus mulyadi on Unsplash
How to access the Laravel ecosystem by simulating the beforeAll
and afterAll
methods in a Pest test.
A sample Laravel project can be found on this Github Repository. Find out more on Capsules, X or Bluesky.
I needed a metaphor to illustrate my article and characterize a Pest test.
Claude: A Pest test is like a hamburger. The core of the test is the juicy patty: it’s the main part, the one that brings all the flavor. Around it, you have the beforeEach
and afterEach
functions, which act as the toppings and sauces, adding flavor and context to each bite. Finally, the beforeAll
and afterAll
functions are like the buns: they provide structure and hold everything together. Together, it all creates a perfectly balanced testing experience. In short, writing a Pest test is like making a good burger: every ingredient matters, and it’s their harmony that makes all the difference.
Hmmm. Okay. Thanks, Claude.
According to the PestPHP documentation, the $this
variable is not accessible in the beforeAll
and afterAll
methods of a test. This is because these hooks are executed before any test is run :
beforeAll()
Executes the provided closure once before any tests are run within the current file, allowing you to perform any necessary setup or initialization that applies to all tests.
beforeAll(function () {
// Prepare something once before any of this file's tests run...
});
It's important to note that unlike the beforeEach() hook, the $this variable is not available in the beforeAll() hook. This is because the hook runs before any tests are executed, so there is no instance of the test class or object to which the variable could refer.
To understand this better, it is important to note that Pest is built on PHPUnit. Pest's beforeAll
and afterAll
functions are based on PHPUnit's setUp
and tearDown
methods, which execute once per test. However, in the case of Pest, these hooks are only called once per file. This is the magic of Pest. The only issue is that nothing is accessible during these calls.
It is therefore impossible to access $this
, the Laravel application, or its properties within a beforeAll()
or afterAll()
hook. At least not without a specific workaround. This article explores that workaround.
From a standard Laravel project, replace PHPUnit with Pest.
composer remove --dev phpunit/phpunit
composer require --dev pestphp/pest --with-all-dependencies
vendor/bin/pest --init
Run the tests with vendor/bin/pest
.
> vendor/bin/pest
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response
Tests: 2 passed (2 assertions)
Duration: 0.21s
If the project uses Vite, a Vite manifest not found
error may occur. To resolve this issue, Vite needs to be disabled in the setUp
method of the TestCase.php
file.
tests/TestCase.php
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp() : void
{
parent::setUp();
$this->withoutVite();
}
}
For the purposes of this article, the Feature
directory is removed. The Feature
test suite must then be deleted from the phpunit.xml
file, along with the corresponding line in the Pest.php
file. Finally, the Unit/ExampleTest.php
file is replaced with Unit/BootloadableTest.php
.
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
</phpunit>
Pest.php
<?php
use Tests\TestCase;
pest()->extend( TestCase::class )->in( 'Unit' );
Unit/BootloadableTest.php
<?php
it( "can say 'Hello World !'", function()
{
$message = 'Hello World !';
echo "$message\n";
expect( $message )->toBeString()->toEqual( 'Hello World !' );
} );
> vendor/bin/pest
Hello World !
PASS Tests\Unit\BootloadableTest
✓ it can say 'Hello World !'
Tests: 1 passed (2 assertions)
Duration: 0.07s
The goal of this article is to share the same data across tests, generated from a single execution of the command php artisan migrate --seed
.
To do this, it is necessary to slightly modify the DatabaseSeeder.php
file to create two User
per seed
.
database/seeders/DatabaseSeeder.php
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
class DatabaseSeeder extends Seeder
{
public function run() : void
{
User::factory( 2 )->create();
}
}
At the start of one test.
tests/Unit/BootloadableTest.php
<?php
use App\Models\User;
it( "can dump users", function()
{
$this->artisan( 'migrate:fresh --seed' );
dd( User::select( 'name', 'email' )->get()->toArray() );
} );
> vendor/bin/pest
array:2 [
0 => array:2 [
"name" => "Evie Cronin"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Heidi Dietrich"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:10
At the start of two identical tests, by adding the Artisan command in the beforeEach()
hook.
tests/Unit/BootloadableTest.php
<?php
use App\Models\User;
beforeEach( function()
{
$this->artisan( 'migrate:fresh --seed' );
} );
it( "can dump users for the first time", function()
{
dump( User::select( 'name', 'email' )->get()->toArray() );
} );
it( "can dump users for the second time", function()
{
dd( User::select( 'name', 'email' )->get()->toArray() );
} );
> vendor/bin/pest
array:2 [
0 => array:2 [
"name" => "Mr. Elmer Jerde I"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Prof. Rupert Toy"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:14
array:2 [
0 => array:2 [
"name" => "Nona Howe MD"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Abbie O'Conner"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:19
The result is predictable, as beforeEach
, as its name suggests, executes before each test. In this case, why not use the beforeAll
hook instead?
tests/Unit/BootloadableTest.php
on line 6
...
beforeAll( function()
{
$this->artisan( 'migrate:fresh --seed' );
} );
...
> vendor/bin/pest
FAIL Tests\Unit\BootloadableTest
─────────────────────────────────────────
FAILED Tests\Unit\BootloadableTest >
Using $this when not in object context
The $this
variable is not accessible in the beforeAll
function, nor are the facades. For example, Artisan::call('migrate:fresh --seed')
does not work in this context either. The alternative : use the Bootloadable
trait.
The trait overrides the setUp
and tearDown
functions to add two new methods : initialize
and finalize
. The initialize
method runs once, like beforeAll
, while the finalize
method also runs once, like afterAll
.
tests\Traits\Bootloadable.php
<?php
namespace Tests\Traits;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Pest\TestSuite;
trait Bootloadable
{
private static int $count = 0;
private static Collection $tests;
protected function setUp() : void
{
parent::setUp();
if( ! self::$count )
{
$this->init();
if( method_exists( self::class, 'initialize' ) ) $this->initialize();
}
self::$count++;
}
protected function tearDown() : void
{
if( count( self::$tests ) == self::$count )
{
if( method_exists( self::class, 'finalize' ) ) $this->finalize();
}
parent::tearDown();
}
private function init() : void
{
$repository = TestSuite::getInstance()->tests;
$data = [];
foreach( $repository->getFilenames() as $file )
{
$factory = $repository->get( $file );
$filename = Str::of( $file )->basename()->explode( '.' )->first();
if( $factory->class === self::class ) $data = [ ...$data, ...[ $filename => $factory->methods ] ];
}
$cases = Collection::make( Arr::dot( $data ) );
$only = $cases->filter( fn( $case ) => Collection::make( $case->groups )->contains( '__pest_only' ) );
self::$tests = ( $only->isEmpty() ? $cases : $only )->keys()->map( fn( $key ) => Str::of( $key )->kebab );
}
}
The setUp
and tearDown
methods are limited to calling initialize
and finalize
. The core logic resides in the init
method.
This method lists the number of tests. The initialize
method is called when the initial count is 0, while incrementing the static variable $count
. The finalize
method is called when this count reaches the length of the $tests
array, initialized by the init
function.
To use the initialize
method, you need to add the trait to the TestCase
and implement the corresponding method.
tests/TestCase.php
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Tests\Traits\Bootloadable;
abstract class TestCase extends BaseTestCase
{
use Bootloadable;
protected function initialize() : void
{
$this->artisan( 'migrate:fresh --seed' );
}
}
- The
initialize
method can thus replace thesetUp
method. In the case of$this->withoutVite
, this configuration can be directly added toinitialize
.
Don’t forget to remove the previous beforeAll()
from the BootloadableTest
file. Then, running vendor/bin/pest
twice will yield the following result : two different migrations and seeds for the same test suite.
> vendor/bin/pest
array:2 [
0 => array:2 [
"name" => "Prof. Moises Skiles IV"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Prof. Cleve Oberbrunner"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:8
array:2 [
0 => array:2 [
"name" => "Prof. Moises Skiles IV"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Prof. Cleve Oberbrunner"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:13
> vendor/bin/pest
array:2 [
0 => array:2 [
"name" => "Doug Marvin"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Joaquin Jacobi"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:8
array:2 [
0 => array:2 [
"name" => "Doug Marvin"
"email" => "[email protected]"
]
1 => array:2 [
"name" => "Joaquin Jacobi"
"email" => "[email protected]"
]
] // tests/Unit/BootloadableTest.php:13
It is now time to manipulate the data across the different tests.
Let’s imagine that the first test modifies the name of the first user, and the second test verifies the updated name from the first test.
Tests/Unit/BootloadableTest.php
<?php
use App\Models\User;
beforeEach( function()
{
$this->user = User::first();
$this->new = "Capsules Codes";
} );
it( "can modify first user name between two tests", function()
{
$name = $this->user->name;
echo $this->user->name;
expect( $this->user->name )->toBe( $name );
$this->user->name = $this->new;
$this->user->save();
} );
it( "can verify first user name between two tests", function()
{
echo " > {$this->user->name} \n";
expect( $this->user->name )->toBe( $this->new );
} );
The result after two consecutive executions of the vendor/bin/pest
command.
> vendor/bin/pest
Esteban Raynor > Capsules Codes
PASS Tests\Unit\BootloadableTest
✓ it can modify first user name between two tests
✓ it can verify first user name between two tests
Tests: 2 passed (2 assertions)
Duration: 0.40s
> vendor/bin/pest
Demario Corkery > Capsules Codes
PASS Tests\Unit\BootloadableTest
✓ it can modify first user name between two tests
✓ it can verify first user name between two tests
Tests: 2 passed (2 assertions)
Duration: 0.39s
What happens if each test is placed in its own file?
Unit/FirstTest.php
<?php
use App\Models\User;
beforeEach( function()
{
$this->user = User::first();
$this->new = "Capsules Codes";
} );
it( "can modify first user name between two tests", function()
{
$name = $this->user->name;
echo "{$this->user->name} > $this->new \n";
expect( $this->user->name )->toBe( $name );
$this->user->name = $this->new;
$this->user->save();
} );
Unit/SecondTest.php
<?php
use App\Models\User;
beforeEach( function()
{
$this->user = User::first();
$this->new = "Capsules Codes";
} );
it( "can verify first user name between two tests", function()
{
expect( $this->user->name )->toBe( $this->new );
} );
vendor/bin/pest
Mrs. Laurine Ebert V > Capsules Codes
PASS Tests\Unit\FirstTest
✓ it can modify first user name between two test files 0.08s
PASS Tests\Unit\SecondTest
✓ it can verify first user name between two test files 0.01s
Tests: 2 passed (2 assertions)
Duration: 0.13s
Unlike Pest, which executes the beforeAll
and afterAll
methods at the start and end of a file's tests, here it operates on a per-TestCase
basis rather than per file. To execute the initialize
and finalize
methods at the beginning and end of the tests for a given file, a slight modification to the Trait is necessary.
tests/Traits/Bootloadable.php
<?php
namespace Tests\Traits;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Pest\TestSuite;
trait Bootloadable
{
private static int $count = 0;
private static array $tests;
private static string $current;
protected function setUp() : void
{
parent::setUp();
self::$current = array_reverse( explode( '\\', debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 )[ 1 ][ 'class' ] ) )[ 0 ];
if( ! isset( self::$tests ) )
{
$this->init();
}
if( ! self::$count )
{
if( method_exists( self::class, 'initialize' ) ) $this->initialize( self::$current );
}
self::$count++;
}
protected function tearDown() : void
{
if( self::$tests[ self::$current ] == self::$count )
{
if( method_exists( self::class, 'finalize' ) ) $this->finalize( self::$current );
self::$count = 0;
}
parent::tearDown();
}
private function init() : void
{
$repository = TestSuite::getInstance()->tests;
$data = [];
foreach( $repository->getFilenames() as $file )
{
$factory = $repository->get( $file );
$filename = Str::of( $file )->basename()->explode( '.' )->first();
if( $factory->class === self::class ) $data = [ ...$data, ...[ $filename => count( $factory->methods ) ] ];
}
self::$tests = $data;
}
}
- The name of the currently running test is retrieved using the
debug_backtrace
function. - The variable
self::$tests
is now an associative array, where each key represents the filename, and each value corresponds to the number of tests it contains. - The variable
self::$count
is reset to zero once thefinalize
method has been executed.
Last step : modify the TestCase
file. This will allow access to the filename within it.
tests/TestCase.php
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Tests\Traits\Bootloadable;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File;
abstract class TestCase extends BaseTestCase
{
use Bootloadable;
protected function initialize( $filename ) : void
{
if( $filename == "FirstTest" )
{
$this->artisan( 'migrate:fresh --seed' );
}
if( $filename == "SecondTest" )
{
$this->artisan( 'migrate:fresh --seed' );
}
}
protected function finalize( $filename ) : void
{
if( $filename == "FirstTest" )
{
$this->artisan( 'migrate:reset' );
}
if( $filename == "SecondTest" )
{
$this->artisan( 'migrate:reset' );
}
}
}
The tests can ben be re-run.
> vendor/bin/test
Brice Fisher > Capsules Codes
PASS Tests\Unit\FirstTest
✓ it can modify first user name between two tests
FAIL Tests\Unit\SecondTest
⨯ it can verify first user name between two tests
─────────────────────────────────────────
FAILED Tests\Unit\SecondTest > it can verify first user name between two tests
Failed asserting that two strings are identical.
-'Capsules Codes'
+'Tremayne Spinka'
at tests/Unit/SecondTest.php:18
14▕
15▕
16▕ it( "can verify first user name between two tests", function()
17▕ {
➜ 18▕ expect( $this->user->name )->toBe( $this->new );
19▕ } );
20▕
1 tests/Unit/SecondTest.php:18
Tests: 1 failed, 1 passed (2 assertions)
Duration: 0.43s
It seems that Brice Fisher
has become Tremayne Spinka
!
Here is an overview of the result obtained by duplicating the BootloadableTest
file into two distinct files. [ Make sure to replace "Capsules Codes" with "Pest PHP" in the second file ].
> vendor/bin/pest
Ernestine Dietrich III > Capsules Codes
PASS Tests\Unit\FirstTest
✓ it can modify first user name between two test files
✓ it can verify first user name between two test files
Dr. Nya Gusikowski > Pest PHP
PASS Tests\Unit\SecondTest
✓ it can modify first user name between two test files
✓ it can verify first user name between two test files
Tests: 4 passed (4 assertions)
Duration: 0.42s
Glad this helped.
driesvints liked this article
Other articles you might like
How to add WebAuthn Passkeys To Backpack Admin Panel
Want to make your Laravel Backpack admin panel more secure with a unique login experience for your a...
Quickest way to setup PHP Environment (Laravel Herd + MySql)
Setting up a local development environment can be a time taking hassle—whether it's using Docker or...
Access Route Model-Bound Models with "#[RouteParameter]"
Introduction I've recently been using the new #[RouteParameter] attribute in Laravel, and I've been...
The Laravel portal for problem solving, knowledge sharing and community building.
The community