Blog / Accéder à Laravel avant et après l'exécution des tests Pest
11 min - 18 Nov 2024
TL;DR: Comment accéder à l’écosystème Laravel en simulant les méthodes beforeAll
et afterAll
d’un test Pest.
Vous trouverez un projet Laravel d’exemple sur ce Github Repository. Découvrez-en plus sur Capsules, X ou Bluesky.
J’avais besoin d’une métaphore pour illustrer mon article et caractériser un test Pest.
Claude : Un test Pest, c’est comme un hamburger. Le cœur du test, c’est la viande juteuse : c’est la partie principale, celle qui fait tout le goût. Autour, vous avez les fonctions beforeEach
et afterEach
, qui jouent le rôle des garnitures et des sauces, apportant saveur et contexte à chaque bouchée. Enfin, les fonctions beforeAll
et afterAll
sont comme les pains : elles donnent de la structure et maintiennent l’ensemble bien en place. Ensemble, tout cela crée une expérience de test parfaitement équilibrée. En somme, écrire un test Pest, c’est comme préparer un bon burger : chaque ingrédient a son importance, et c’est leur harmonie qui fait toute la différence.
Mouais. Ok. Merci Claude.
D’après la documentation de PestPHP, la variable $this
n’est pas accessible dans les méthodes beforeAll
ou afterAll
d’un test. Cela s’explique par le fait que ces hooks sont exécutés avant le lancement de tout test.
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.
Pour en comprendre davantage, il est important de rappeler que Pest repose sur PHPUnit. Les fonctions beforeAll
et afterAll
de Pest sont basées respectivement sur les méthodes setUp
et tearDown
de PHPUnit, qui s’exécutent une fois par test. Cependant, dans le cas de Pest, ces hooks ne sont appelés qu’une seule fois par fichier. C’est là toute la magie de Pest. Le seul problème, c’est que rien n’est encore accessible lors de ces appels.
Il est donc impossible d’accéder à $this
, à l’application Laravel ou à ses propriétés dans un beforeAll()
ou un afterAll()
. Du moins, pas sans un ajustement spécifique. Cet article explore cet ajustement.
À partir d’un projet Laravel standard, remplacer PHPUnit par Pest.
composer remove --dev phpunit/phpunit
composer require --dev pestphp/pest --with-all-dependencies
vendor/bin/pest --init
Exécuter les tests avec 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
Si le projet utilise Vite, une erreur Vite manifest not found
peut survenir. pour résoudre ce problème, Il faut désactiver Vite dans la méthode setUp
du fichier TestCase.php
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();
}
}
Pour les besoins de cet article, le dossier Feature
est supprimé. Le testsuite Feature
doit alors être supprimé du fichier phpunit.xml
. Ainsi que la ligne dans le fichier Pest.php
. Il ne reste plus qu’a modifier le fichier ExampleTest.php
par 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>
tests/Pest.php
<?php
use Tests\TestCase;
pest()->extend( TestCase::class )->in( 'Unit' );
tests/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
L’objectif de cet article est de partager entre les test les mêmes données issues d’une unique génération d’utilisateur, réalisée via une seul commande php artisan migrate --seed
.
Pour ce faire, il est nécessaire de légèrement modifier le fichier DatabaseSeeder.php
afin de créer deux Users
par 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();
}
}
Au départ d’un 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" => "ebert.paolo@example.org"
]
1 => array:2 [
"name" => "Heidi Dietrich"
"email" => "orempel@example.net"
]
] // tests/Unit/BootloadableTest.php:10
Au départ de deux tests identiques, en ajoutant la commande artisan
dans le hook beforeEach()
:
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" => "hector.okuneva@example.net"
]
1 => array:2 [
"name" => "Prof. Rupert Toy"
"email" => "bosco.ferne@example.com"
]
] // tests/Unit/BootloadableTest.php:14
array:2 [
0 => array:2 [
"name" => "Nona Howe MD"
"email" => "zulauf.jarred@example.com"
]
1 => array:2 [
"name" => "Abbie O'Conner"
"email" => "lhowell@example.net"
]
] // tests/Unit/BootloadableTest.php:19
Le résultat étant à prévoir, puisque beforeEach
, comme son nom l’indique, s’exécute avant chacun des tests. Dans ce cas, pourquoi ne pas utiliser plutôt le hook beforeAll
?
tests/Unit/BootloadableTest.php
à la ligne 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
La variable $this
n’est pas accessible dans la fonction beforeAll
, Tout comme les façades. Par exemple, Artisan::call( 'migrate:fresh --seed' )
ne fonctionne pas non plus dans ce context. L’alternative : utiliser le Trait Bootloadable
.
Le trait réecrit les fonctions setUp
et tearDown
pour y ajouter deux nouvelles méthodes initialize
et finalize
. La méthode initialize
s’exécute une seule fois, tel beforeAll
, tandis que la méthode finalize
est également exécutée une seule fois, comme 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 );
}
}
Les méthodes setUp
et tearDown
se limitent à appeler initialize
et finalize
. L’essentiel de la logique réside dans la méthode init
.
Cette méthode répertorie le nombre de tests. La méthode initialize
est appelée lorsque le nombre intial est égal à 0, tout en incrémentant la variable statique $count
. De son côté, la méthode finalize
est appelée lorsque ce nombre atteint la longueur du tableau $tests
, initialisé par la fonction init
.
Pour utiliser la méthode initialize
, il est nécessaire d’ajouter le Trait au TestCase
et d’y implémenter la méthode correspondante.
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' );
}
}
initialize
peut ainsi remplacer la méthode setUp
. Dans le cas de $this->withoutVite
, cette configuration peut être directement ajoutée à initialize
.Sans oublier de supprimer le précédent beforeAll()
du fichier BootloadableTest
. Lancer ensuite vendor/bin/pest
à deux reprises retournera ce résultat : deux migrations et seeds différents pour une même série de tests :
> vendor/bin/pest
array:2 [
0 => array:2 [
"name" => "Prof. Moises Skiles IV"
"email" => "frederique.nitzsche@example.com"
]
1 => array:2 [
"name" => "Prof. Cleve Oberbrunner"
"email" => "madelynn.hane@example.net"
]
] // tests/Unit/BootloadableTest.php:8
array:2 [
0 => array:2 [
"name" => "Prof. Moises Skiles IV"
"email" => "frederique.nitzsche@example.com"
]
1 => array:2 [
"name" => "Prof. Cleve Oberbrunner"
"email" => "madelynn.hane@example.net"
]
] // tests/Unit/BootloadableTest.php:13
> vendor/bin/pest
array:2 [
0 => array:2 [
"name" => "Doug Marvin"
"email" => "nash.schoen@example.com"
]
1 => array:2 [
"name" => "Joaquin Jacobi"
"email" => "neoma38@example.net"
]
] // tests/Unit/BootloadableTest.php:8
array:2 [
0 => array:2 [
"name" => "Doug Marvin"
"email" => "nash.schoen@example.com"
]
1 => array:2 [
"name" => "Joaquin Jacobi"
"email" => "neoma38@example.net"
]
] // tests/Unit/BootloadableTest.php:13
Il est maintenant temps de manipuler les données à travers les différents tests.
Imaginons que le premier test modifie le nom du premier User
et que le second test vérifie les nom mis à jour lors du premier 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 );
} );
Le résultat après deux exécutions consécutives de la commande vendor/bin/pest
:
> 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
Que se passe-t-il si chaque test est placé dans son propre fichier ?
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
Contrairement au fonctionnement de Pest, qui exécute les méthodes beforeAll
et afterAll
au début et à la fin des tests d'un fichier, on constate qu’il s’agit ici d’une série de tests par TestCase
, plutôt que par fichier. Pour exécuter les méthodes initialize
et finalize
au début et à la fin des tests d’un fichier donné, il est nécessaire d’apporter une légère modification au Trait.
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;
}
}
debug_backtrace
.self::$tests
est désormais un tableau associatif, où chaque clef représente le nom du fichier, et chaque valeur correspond au nombre de tests qu’il contient.self::count
est réinitialisée à zero une fois que la méthode finalize
ait été exécutée.Dernière étape, modifier le fichier TestCase
. Dans lequel il est dès lors possible d’accéder au nom du fichier.
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' );
}
}
}
Les tests peuvent être relancés.
> vendor/bin/pest
Brice Fisher > Capsules Codes
PASS Tests\Unit\FirstTest
✓ it can modify first user name between two tests 0.36s
FAIL Tests\Unit\SecondTest
⨯ it can verify first user name between two tests 0.03s
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
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
Il semblerait que Brice Fisher
soit devenu Tremayne Spinka
!
Voici un aperçu du résultat obtenu en dupliquant le fichier BootloadableTest
en deux fichiers distincts. [ En s’assurant de remplacer Capsules Codes
par Pest PHP
dans le second fichier ].
> 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
Ravi d’avoir pu aider !