DEV Community

Cover image for Access Laravel before and after running Pest tests
Capsules Codes
Capsules Codes

Posted on • Originally published at capsules.codes

Access Laravel before and after running Pest tests

TL;DR: 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.
Enter fullscreen mode Exit fullscreen mode

 
 

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
Enter fullscreen mode Exit fullscreen mode

 
 

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
Enter fullscreen mode Exit fullscreen mode

 
 

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

 
 

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>
Enter fullscreen mode Exit fullscreen mode

 
 

Pest.php


<?php

use Tests\TestCase;

pest()->extend( TestCase::class )->in( 'Unit' );

Enter fullscreen mode Exit fullscreen mode

 
 

Unit/BootloadableTest.php

<?php

it( "can say 'Hello World !'", function()
{
    $message = 'Hello World !';

    echo "$message\n";

    expect( $message )->toBeString()->toEqual( 'Hello World !' );
} );
Enter fullscreen mode Exit fullscreen mode

 

> vendor/bin/pest

Hello World !

PASS  Tests\Unit\BootloadableTest
✓ it can say 'Hello World !'                                                                                                                                                                                            

Tests:    1 passed (2 assertions)
Duration: 0.07s
Enter fullscreen mode Exit fullscreen mode

 
 

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

 
 

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() );
} );
Enter fullscreen mode Exit fullscreen mode

 

> 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
Enter fullscreen mode Exit fullscreen mode

 
 

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() );
} );
Enter fullscreen mode Exit fullscreen mode

 

> 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
Enter fullscreen mode Exit fullscreen mode

 
 

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

...
Enter fullscreen mode Exit fullscreen mode

 

> vendor/bin/pest

FAIL  Tests\Unit\BootloadableTest
─────────────────────────────────────────
FAILED  Tests\Unit\BootloadableTest > 

Using $this when not in object context
Enter fullscreen mode Exit fullscreen mode

 
 

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 );
    }
}
Enter fullscreen mode Exit fullscreen mode

 
 

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

Enter fullscreen mode Exit fullscreen mode
  • The initialize method can thus replace the setUp method. In the case of $this->withoutVite, this configuration can be directly added to initialize.

 
 

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" => "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
Enter fullscreen mode Exit fullscreen mode

 
 

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 );
} );
Enter fullscreen mode Exit fullscreen mode

 
 

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
Enter fullscreen mode Exit fullscreen mode

 
 

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();
} );
Enter fullscreen mode Exit fullscreen mode

 
 

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 );
} );
Enter fullscreen mode Exit fullscreen mode

 

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
Enter fullscreen mode Exit fullscreen mode

 
 

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;
    }
}

Enter fullscreen mode Exit fullscreen mode
  • 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 the finalize 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' );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

 
 

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                                                                                                                                                                                           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
Enter fullscreen mode Exit fullscreen mode

 
 

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
Enter fullscreen mode Exit fullscreen mode

 
 

Glad this helped.

Top comments (0)