PHPUnit Lifecycle Hooks: setUpBeforeClass in Detail
AI generated
@test
assert
PHPUnit · Lifecycle · Test Isolation
PHPUnit Lifecycle Hooks in Detail
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.

16 min read setUpBeforeClass · setUp · tearDown Test Isolation

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.

11. FAQ: PHPUnit Lifecycle Hooks: The Essentials at a Glance

1Why does setUpBeforeClass have to be static?
Because the method runs before any instance of the test class exists at all. PHPUnit creates a new object instance for every test, but setUpBeforeClass is called before that, so only static methods and properties are reachable.
2Does tearDown still run when the test throws an exception?
Yes, PHPUnit internally catches the exception and calls tearDown anyway, before reporting the failure as a test failure. Cleanup inside tearDown is therefore guaranteed to happen.
3What happens if I forget parent::setUp() in a child class?
The base class preparation is skipped entirely, with no warning from PHPUnit. This often leads to hard to trace failures, such as an uninitialized database connection.
4Why should parent::tearDown() sit at the end rather than the start?
So resources are torn down in the reverse order in which they were built up, last built up is first torn down. This avoids failures when child class cleanup still depends on base class resources.
5Can I access $this inside setUpBeforeClass?
No, since no object instance exists yet, $this is not available inside a static method. Access only happens via self:: on static properties and static methods.
6How do I detect leaking state in an existing test suite?
The most reliable way is running the suite with --order-by=random. Tests that suddenly fail even though they pass individually or in the default order almost always point to shared static state.
7Should I open a database connection in setUp or setUpBeforeClass?
It depends on the purpose: a plain connection with no mutated state belongs in setUpBeforeClass for performance, but as soon as tests write data, a transaction should start in setUp per test and roll back in tearDown.
8What is the difference between setUp and the test class constructor?
PHPUnit does call a fresh constructor for every test, but recommends placing preparation logic in setUp rather than the constructor, because setUp is documented as an explicit lifecycle hook and is clearly recognized by tools such as IDEs.
9Is tearDownAfterClass mandatory whenever setUpBeforeClass is used?
Not strictly, but advisable as soon as setUpBeforeClass opens a resource that should be explicitly closed, such as a database connection or a file handle, to avoid resource leaks across multiple test classes.
10Are there attributes as an alternative to the fixed lifecycle method names?
Yes, since PHPUnit 10 there are #[Before], #[After], #[BeforeClass], and #[AfterClass], which let arbitrarily named methods be marked as lifecycle hooks instead of sticking to the fixed method names.