The complete order from setUpBeforeClass to tearDownAfterClass, and what leaks when it is used incorrectly
PHPUnit runs four different lifecycle hooks in a fixed order, two of them once per class, two of them before and after every individual test. Anyone unclear on this precise order easily ends up building tests that appear isolated but actually share state through static properties. This article walks through the full chain, the typical mistakes, and when static instead of instance methods are actually needed.
Table of Contents
- 1. The four lifecycle phases at a glance
- 2. setUpBeforeClass: once per class, mandatorily static
- 3. setUp and tearDown: the actual isolation mechanism
- 4. The classic mistake: static state leaks between tests
- 5. Order with multiple setUp implementations in inheritance chains
- 6. tearDown runs in reverse order relative to setUp
- 7. When static instead of instance methods are truly needed
- 8. assertPreConditions and assertPostConditions as supplementary hooks
- 9. Takeaway: lifecycle discipline as the foundation for reliable tests
- 10. Summary
- 11. FAQ
1. The four lifecycle phases at a glance
For every test class, PHPUnit runs through a fixed sequence of four phases: setUpBeforeClass runs exactly once, before the first test of the class starts. Then, for every individual test, the pair setUp, right before the test body, and tearDown, right after it, runs, regardless of whether the test passed or failed. Finally, tearDownAfterClass runs exactly once, after the last test of the class has finished.
This order is not implementation detail trivia, it is the foundation of correct test isolation. Every test should produce the same result regardless of the execution order of the other tests, and that is exactly what setUp and tearDown are for: a guaranteed fresh state before every individual test. Violating this principle produces tests that only pass when run in a particular order, a classic sign of hidden coupling.
2. setUpBeforeClass: once per class, mandatorily static
setUpBeforeClass must be declared as a static method, because it runs before any instance of the test class exists at all, PHPUnit instantiates a new object instance for every test, but setUpBeforeClass belongs before that in time. Typical use cases are expensive, one time preparations: establishing a test database connection, parsing a fixture file once, starting a test container.
The critical pitfall is that anything stored in a static property inside setUpBeforeClass is shared across every test in the class. If a test mutates this state, the next test already sees the mutated version, not the original one. That is why setUpBeforeClass should hold exclusively truly immutable or read only state, never anything a test mutates during its execution.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Integration;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PDO;
final class OrderRepositoryDatabaseTest extends TestCase
{
private static PDO $connection;
public static function setUpBeforeClass(): void
{
// Build the expensive connection once, for all tests in this class
self::$connection = new PDO('sqlite::memory:');
self::$connection->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT)');
}
public static function tearDownAfterClass(): void
{
// Release the connection, drop the reference
unset(self::$connection);
}
#[Test]
public function itStoresAnOrderInTheDatabase(): void
{
self::$connection->exec("INSERT INTO orders (sku) VALUES ('ABC-123')");
$count = self::$connection->query('SELECT COUNT(*) FROM orders')->fetchColumn();
self::assertSame(1, (int) $count);
}
}
3. setUp and tearDown: the actual isolation mechanism
setUp and tearDown run as instance methods before and after every individual test, because PHPUnit creates a fresh object instance of the test class for every test. Instance properties set inside setUp therefore automatically exist only for the duration of exactly one test, that is the built in isolation mechanism many developers underestimate simply because it works so unobtrusively.
tearDown is guaranteed to run even when the test fails with an exception or an assertion is not met, PHPUnit internally catches the failure and calls tearDown anyway. That makes tearDown the right place for cleanup that has to happen even after a failing test, such as closing a temporary file or resetting global state that sits outside PHPUnit's own control.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ShoppingCartTest extends TestCase
{
private ShoppingCart $cart;
protected function setUp(): void
{
// New instance before EVERY test, guaranteed fresh state
$this->cart = new ShoppingCart();
}
protected function tearDown(): void
{
// Runs even on a failing test
$this->cart->clear();
}
#[Test]
public function itAddsAProduct(): void
{
$this->cart->add('SKU-1', 2);
self::assertSame(2, $this->cart->quantityOf('SKU-1'));
}
#[Test]
public function theCartIsEmptyForEveryTest(): void
{
// No leftover from the previous test, thanks to setUp
self::assertTrue($this->cart->isEmpty());
}
}
4. The classic mistake: static state leaks between tests
The most common lifecycle bug happens when a developer uses a static property to avoid repeated setup inside setUp, and overlooks that this property is then shared by every test in the class. If test A runs first and mutates the static state, test B already sees the mutated version, even though B assumes it is starting from an untouched initial state.
Such bugs are especially treacherous because they often only surface once the execution order changes, for example through a PHPUnit update, a new test file in the same directory, or enabling random test order via --order-by=random. A test that passes in isolation but fails within the full suite is almost always a sign of exactly this pattern.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
// WRONG: static state leaks between tests
final class CounterLeakyTest extends TestCase
{
private static int $counter = 0;
#[Test]
public function firstCall(): void
{
self::$counter++;
self::assertSame(1, self::$counter); // passes only if this test runs first
}
#[Test]
public function secondCall(): void
{
self::$counter++;
self::assertSame(1, self::$counter); // fails if the order changes
}
}
// RIGHT: state belongs in an instance property, reset via setUp
final class CounterIsolatedTest extends TestCase
{
private int $counter = 0;
protected function setUp(): void
{
$this->counter = 0;
}
#[Test]
public function firstCall(): void
{
$this->counter++;
self::assertSame(1, $this->counter);
}
}
5. Order with multiple setUp implementations in inheritance chains
If a test class inherits from an abstract base class that itself defines a setUp method, the overriding setUp method in the child class must explicitly call parent::setUp(), PHPUnit does not automatically call both implementations. Forgetting this call silently drops the base class preparation, without PHPUnit issuing any warning, a mistake that is particularly easy to overlook in multi level test class hierarchies.
The execution order when parent::setUp() is called explicitly depends on whether that call sits at the beginning or the end of the child class method. A proven convention is to consistently place parent::setUp() as the first line, so the base preparation always runs before the child class's specific preparation, mirroring the usual constructor convention with parent::__construct().
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Unit;
abstract class DatabaseTestCase extends \PHPUnit\Framework\TestCase
{
protected \PDO $connection;
protected function setUp(): void
{
$this->connection = new \PDO('sqlite::memory:');
}
}
final class OrderRepositoryTest extends DatabaseTestCase
{
private OrderRepository $repository;
protected function setUp(): void
{
parent::setUp(); // base preparation MUST run first
$this->repository = new OrderRepository($this->connection);
}
}
6. tearDown runs in reverse order relative to setUp
A lesser known detail concerns tearDown in inheritance chains: here too, the child class must explicitly call parent::tearDown(), but the recommended convention is reversed compared to setUp, parent::tearDown() should sit at the end of the overriding method. This way, state is torn down in the reverse order in which it was built up, last built up means first torn down, a pattern familiar from stack based resource management.
In practice this detail matters most when tearDown at several levels closes resources such as file handles or network connections that depend on each other. If the base class connection is closed too early while the child class still wants to clean something up through it, an exception is thrown inside tearDown itself, which PHPUnit reports as a separate error in addition to the actual test result.
7. When static instead of instance methods are truly needed
static belongs exclusively to setUpBeforeClass and tearDownAfterClass, because these run before the first and after the last instantiation respectively. The legitimate use case for static state is an expensive but immutable resource, a spun up test container, a large fixture file parsed once, an in memory database connection whose schema does not change throughout the entire test class.
As soon as a test actively mutates this static state, for example writing rows into a table, an explicit reset in tearDown that restores the original state belongs there, for example a transaction started per test and rolled back in tearDown. This combination of a static resource in setUpBeforeClass and a transaction rollback in tearDown is a proven pattern for fast yet still isolated database tests.
<?php
declare(strict_types=1);
namespace Mironsoft\Tests\Integration;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PDO;
final class OrderRepositoryTransactionalTest extends TestCase
{
private static PDO $connection;
public static function setUpBeforeClass(): void
{
self::$connection = new PDO('sqlite::memory:');
self::$connection->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT)');
}
protected function setUp(): void
{
self::$connection->beginTransaction();
}
protected function tearDown(): void
{
// Rollback restores the original state for the next test
self::$connection->rollBack();
}
#[Test]
public function itStoresAnOrder(): void
{
self::$connection->exec("INSERT INTO orders (sku) VALUES ('X-1')");
self::assertSame(1, (int) self::$connection->query('SELECT COUNT(*) FROM orders')->fetchColumn());
}
}
8. assertPreConditions and assertPostConditions as supplementary hooks
Besides the four main hooks, PHPUnit offers two lesser known methods, assertPreConditions, which runs right after setUp, and assertPostConditions, which runs right before tearDown. Both are specifically meant for checking conditions that logically belong to the test setup but should stay separate from the actual test body, for example a general check that the database connection is actually active after it was built.
In practice these two hooks are rarely needed, because their job can usually be handled just as well inside setUp or at the end of the test method. They are mainly useful in base classes inherited by many test classes, wanting to check an invariant without every single child class having to repeat that check explicitly, for example verifying that no open database transaction remains after every test.
9. Takeaway: lifecycle discipline as the foundation for reliable tests
PHPUnit's lifecycle order is simple in itself, setUpBeforeClass, then per test setUp and tearDown, finally tearDownAfterClass. The error proneness comes entirely from static state that should actually be instance bound, or from forgotten parent calls in inheritance chains. Both can be avoided with one clear rule: static exclusively for state that is either truly immutable or reset transactionally.
Anyone writing tests that happen to need a particular run order to stay green has usually violated exactly this principle. A good practical test is to run the suite regularly with --order-by=random, leaking dependencies almost always surface immediately, long before they turn into a real problem in production.
| Hook | Frequency | static required | Typical use |
|---|---|---|---|
| setUpBeforeClass | Once per class, before all tests | Yes | Build an expensive, immutable resource |
| setUp | Before every individual test | No | Establish a fresh instance state |
| tearDown | After every individual test | No | Clean up, even on failure |
| tearDownAfterClass | Once per class, after all tests | Yes | Release a resource for good |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
PHPUnit Lifecycle Hooks: The Essentials at a Glance
Core idea
setUpBeforeClass runs once per class, setUp and tearDown before and after every individual test.
Biggest pitfall
static properties share state across every test once they are mutated during execution.
Inheritance
parent::setUp() belongs at the start, parent::tearDown() at the end of the overriding method.
Diagnostic tip
--order-by=random almost always surfaces leaking state immediately.