[Bug]: afterEach Hook Execution Order Is Inverted Compared to Documentation
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 thePest.phpconfiguration file will be executed prior to hooks defined in individual test files. Similarly, anyafter*hooks specified in thePest.phpconfiguration 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:
- The global hook runs first
- Then the outermost test file hook
- Then any intermediate nested hooks
- Finally the innermost hook
How to Reproduce
I've created a minimal test case to demonstrate this issue:
- Set up a fresh Laravel project with Pest:
composer global require laravel/installer
laravel new example-app
(Selected Pest for testing during installation)
- 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();
});
});
});
- 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 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:
- The
beforeEachhooks should execute in order: global → outer → middle → inner (this works correctly) - The test runs
- The
afterEachhooks 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.
Is someone else able to reproduce this?
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.
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?
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.
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.
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. :(