pest icon indicating copy to clipboard operation
pest copied to clipboard

[Bug]: afterEach Hook Execution Order Is Inverted Compared to Documentation

Open jonathanpmartins opened this issue 9 months ago • 1 comments

When using afterEach hooks at different levels (global in Pest.php and in test files), the execution order is completely reversed compared to what's stated in the documentation.

According to the Global Hooks documentation, it states:

Any before* hooks defined in the Pest.php configuration file will be executed prior to hooks defined in individual test files. Similarly, any after* hooks specified in the Pest.php configuration file will be executed after any hooks defined in individual test files.

However, I've observed that the afterEach hooks are executed in the reverse order from what's documented:

  1. The global hook runs first
  2. Then the outermost test file hook
  3. Then any intermediate nested hooks
  4. Finally the innermost hook

How to Reproduce

I've created a minimal test case to demonstrate this issue:

  1. Set up a fresh Laravel project with Pest:
composer global require laravel/installer
laravel new example-app

(Selected Pest for testing during installation)

  1. Created a nested test structure in tests/Unit/ExampleTest.php:
<?php

beforeEach(function ()
{
    dump('1');
});

afterEach(function ()
{
    dump('6');
});

test('Outer Test', function ()
{
    dump('Outer Test');
    expect(true)->toBeTrue();
});

describe('Group 1', function ()
{
    beforeEach(function ()
    {
        dump('2');
    });

    afterEach(function ()
    {
        dump('5');
    });

    test('Middle Test', function ()
    {
        dump('Middle Test');
        expect(true)->toBeTrue();
    });

    describe('Group 2', function ()
    {
        beforeEach(function ()
        {
            dump('3');
        });

        afterEach(function ()
        {
            dump('4');
        });

        test('Inner test', function ()
        {
            dump('Inner test');
            expect(true)->toBeTrue();
        });
    });
});
  1. Added a global hook in tests/Pest.php:
pest()->beforeEach(function ()
{
    dump('0 global beforeEach()');
});

pest()->afterEach(function ()
{
    dump('7 global afterEach()');
});

Test Output

$ php artisan test
^ "0 global beforeEach()"
^ "1"
^ "Outer Test"
^ "7 global afterEach()"
^ "6"
^ "0 global beforeEach()"
^ "1"
^ "2"
^ "Middle Test"
^ "7 global afterEach()"
^ "6"
^ "5"
^ "0 global beforeEach()"
^ "1"
^ "2"
^ "3"
^ "Inner test"
^ "7 global afterEach()" <-- Should be last, runs first
^ "6"                    <-- Should be second-to-last, runs second
^ "5"                    <-- Should be third-to-last, runs third
^ "4"                    <-- Should be first, runs last
   PASS  Tests\Unit\ExampleTest
  ✓ Outer Test
  ✓ Group 1 → Middle Test
  ✓ Group 1 → Group 2 → Inner test
  Tests:    3 passed (3 assertions)
  Duration: 0.03s

Sample Repository

pest-bug-example-repo

Pest Version

3.8.2

PHP Version

8.4.6

Operation System

Linux

Expected Behavior

According to the documentation, for a test in the innermost describe block:

  1. The beforeEach hooks should execute in order: global → outer → middle → inner (this works correctly)
  2. The test runs
  3. The afterEach hooks should execute in the reverse order: inner → middle → outer → global

However, the actual output shows the afterEach hooks running in the exact opposite order: global → outer → middle → inner.

Additional observations

The afterEach hooks are completely reversed from what would be expected. This behavior is counterintuitive and makes it difficult to properly clean up resources in a consistent manner. Since beforeEach hooks set up state in a specific order, it would be logical for afterEach hooks to clean up in the reverse order.

jonathanpmartins avatar May 07 '25 02:05 jonathanpmartins

Is someone else able to reproduce this?

jonathanpmartins avatar May 14 '25 18:05 jonathanpmartins

Is someone else able to reproduce this?

I just added the example test and the global hooks to a fresh laravel project and ran vendor/bin/pest tests/Unit/ExampleTest.php. My output is exactly as you described in the bug report.

tpraxl avatar Jul 06 '25 13:07 tpraxl

Maybe this test is a good headstart?

https://github.com/pestphp/pest/blob/3.x/tests/Hooks/AfterEachTest.php

How would you change it to reflect the documented (and most likely the intuitive) order?

tpraxl avatar Jul 06 '25 14:07 tpraxl

Thank you for the response!

The test should output something like this:

^ "0 global beforeEach()"
^ "1"
^ "Outer Test"
^ "6"
^ "7 global afterEach()"
^ "0 global beforeEach()"
^ "1"
^ "2"
^ "Middle Test"
^ "5"
^ "6"
^ "7 global afterEach()"
^ "0 global beforeEach()"
^ "1"
^ "2"
^ "3"
^ "Inner test"
^ "4"
^ "5"
^ "6"
^ "7 global afterEach()"

The core issue is this: I need to be able to set up resources as tests become more deeply nested (through beforeEach hooks), and then tear down those same resources in reverse order as the test completes and "unwinds" from the nesting (through afterEach hooks).

This is the standard pattern in most testing frameworks:

  • Setup phase: Global → Outer → Middle → Inner (currently working correctly)
  • Teardown phase: Inner → Middle → Outer → Global (currently broken - executing in reverse)

Without this proper ordering, it becomes very difficult to manage dependencies and clean up resources correctly, especially when outer scopes create resources that inner scopes depend on.

jonathanpmartins avatar Jul 07 '25 20:07 jonathanpmartins

I've created a comprehensive test to demonstrate the issue and a potential solution:

I created a file named HooksOrderTest.php to show the expected behavior:

pest()->beforeEach(function () {
    $this->setupOrder = [];
    $this->teardownOrder = [];

    $this->setupOrder[] = 'global-beforeEach';
});

beforeEach(function () {
    $this->setupOrder[] = 'local-beforeEach';
});

afterEach(function () {
    $this->teardownOrder[] = 'local-afterEach';
});

pest()->afterEach(function () {
    $this->teardownOrder[] = 'global-afterEach';

    assertAfterEachHooksOrder($this->name());
});

function assertAfterEachHooksOrder(string $name) {
    $data = match ($name) {
        '__pest_evaluable_simple_test' => [
            [
                'global-beforeEach',
                'local-beforeEach',
            ],
            [
                'local-afterEach',
                'global-afterEach',
            ],
        ],
        '__pest_evaluable__nested_1__→_nested_test' => [
            [
                'global-beforeEach',
                'local-beforeEach',
                'nested-beforeEach-1',
            ],
            [
                'nested-afterEach-1',
                'local-afterEach',
                'global-afterEach',
            ],
        ],
        '__pest_evaluable__nested_1__→__nested_2__→_setup_and_teardown_order_should_be_reversed' => [
            [
                'global-beforeEach',
                'local-beforeEach',
                'nested-beforeEach-1',
                'nested-beforeEach-2',
            ],
            [
                'nested-afterEach-2',
                'nested-afterEach-1',
                'local-afterEach',
                'global-afterEach',
            ],
        ],
        '__pest_evaluable__nested_1__→__nested_2__→__nested_3__→_setup_and_teardown_order_should_be_reversed' => [
            [
                'global-beforeEach',
                'local-beforeEach',
                'nested-beforeEach-1',
                'nested-beforeEach-2',
                'nested-beforeEach-3',
            ],
            [
                'nested-afterEach-3',
                'nested-afterEach-2',
                'nested-afterEach-1',
                'local-afterEach',
                'global-afterEach',
            ],
        ],
        default => test()->fail('Unexpected test name: '.$name),
    };

    expect(test()->setupOrder)->toBe($data[0]);
    expect(test()->teardownOrder)->toBe($data[1]);

}

test('simple test', function () {
    expect($this->setupOrder)->toBe([
        'global-beforeEach',
        'local-beforeEach',
    ]);
    expect($this->teardownOrder)->toBe([]);
});

describe('nested 1', function () {
    beforeEach(function () {
        $this->setupOrder[] = 'nested-beforeEach-1';
    });

    afterEach(function () {
        $this->teardownOrder[] = 'nested-afterEach-1';
    });

    test('nested test', function () {
        expect($this->setupOrder)->toBe([
            'global-beforeEach',
            'local-beforeEach',
            'nested-beforeEach-1',
        ]);
        expect($this->teardownOrder)->toBe([]);
    });

    describe('nested 2', function () {
        beforeEach(function () {
            $this->setupOrder[] = 'nested-beforeEach-2';
        });

        afterEach(function () {
            $this->teardownOrder[] = 'nested-afterEach-2';
        });

        test('setup and teardown order should be reversed', function () {
            expect($this->setupOrder)->toBe([
                'global-beforeEach',
                'local-beforeEach',
                'nested-beforeEach-1',
                'nested-beforeEach-2',
            ]);
            expect($this->teardownOrder)->toBe([]);
        });

        describe('nested 3', function () {
            beforeEach(function () {
                $this->setupOrder[] = 'nested-beforeEach-3';
            });

            afterEach(function () {
                $this->teardownOrder[] = 'nested-afterEach-3';
            });

            test('setup and teardown order should be reversed', function () {
                expect($this->setupOrder)->toBe([
                    'global-beforeEach',
                    'local-beforeEach',
                    'nested-beforeEach-1',
                    'nested-beforeEach-2',
                    'nested-beforeEach-3',
                ]);
                expect($this->teardownOrder)->toBe([]);
            });
        });
    });
});

I also explored a potential fix by adding a reverseBound() method to the ChainableClosure class:

/**
 * Calls the given `$closure` and chains the `$next` closure in reverse order, "bound" to the same object.
 * This is useful for teardown hooks where the execution order should be LIFO (Last In, First Out).
 */
public static function reverseBound(Closure $closure, Closure $next): Closure
{
    return function (...$arguments) use ($closure, $next): void {
        if (! is_object($this)) { // @phpstan-ignore-line
            throw ShouldNotHappen::fromMessage('$this not bound to chainable closure.');
        }

        \Pest\Support\Closure::bind($next, $this, self::class)(...$arguments);
        \Pest\Support\Closure::bind($closure, $this, self::class)(...$arguments);
    };
}

This method simply inverts the order of the bind calls compared to the regular bound() method.

I then modified the AfterEachRepository::set() and Testable::tearDown() methods to use reverseBound() instead of bound(). While this change makes my test pass, it causes other existing tests to fail, which suggests this fix might be too simplistic and could have broader implications.

jonathanpmartins avatar Jul 07 '25 22:07 jonathanpmartins

Thanks for providing the test. I also started writing some tests and there is clearly something very wrong with the execution order. However, the related Pest logic is pretty hard to grasp. I hope for a maintainer to care. Until then, despite its great ideas and features, I hesitate to use Pest for enterprise code. Wrong order in hooks is a guarantee for side-effects that make you or your teammates spend way too much time on searching in the wrong direction. :(

tpraxl avatar Jul 09 '25 16:07 tpraxl