Blog / Accéder à Laravel avant et après l'exécution des tests Pest

Image used for article Accéder à Laravel avant et après l'exécution des tests Pest

Accéder à Laravel avant et après l'exécution des tests Pest

Avatar de l'auteur
Yannick - Designer | Developer

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 CapsulesX 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' );
    }
}

  • La méthode 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;
    }
}

  • Le nom du test en cours d’exécution est récupéré à l’aide de la fonction debug_backtrace.
  • La variable 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.
  • La variable 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
     141516▕ it( "can verify first user name between two tests", function()
     17▕ {
  ➜  18expect( $this->user->name )->toBe( $this->new );
     19▕ } );
     201   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 !

v1.4.0

Icône XIcône Github