Test isolation — database transactions, setUp, tearDown
Concept
Test isolation is the guarantee that each test starts from a known state and doesn't influence subsequent tests. The most common isolation violations in PHP tests involve shared database state, global state (static properties, superglobals), and shared filesystem artifacts.
The canonical approach for database isolation is database transactions: wrap each test in a transaction during setUp() and roll it back in tearDown(). The database never actually changes from the perspective of other tests because the transaction is never committed. This is dramatically faster than truncating tables between tests. The limitation is that code inside the test that calls DB::beginTransaction() manually can break rollback—nested transactions behave differently across databases.
The setUp() method runs before each test method; tearDown() runs after. setUpBeforeClass() and tearDownAfterClass() are static methods that run once per class—useful for expensive setup like creating a database schema or spinning up a process, but dangerous for mutable state (class-level state leaks between tests within the class).
For stateful global dependencies (like $_SESSION, $_SERVER, or static caches), the cleanest approach is to save and restore in setUp()/tearDown(). Better still: don't use superglobals in application code—wrap them in a Request object so tests can substitute fakes.
Laravel's RefreshDatabase and DatabaseTransactions traits implement exactly this pattern. DatabaseTransactions wraps each test in a transaction and rolls back. RefreshDatabase re-runs all migrations before each test (much slower, but handles seeder fixtures and multi-database scenarios that transactions can't).
In PHPUnit without a framework, use the Doctrine\ORM\Tools\SchemaTool or SQLite :memory: databases that start fresh per process. The key rule: if a test leaves a side effect that another test could observe, it's not isolated.
Code Example
<?php
declare(strict_types=1);
namespace Tests\Integration;
use App\Repositories\OrderRepository;
use App\Models\Order;
use PDO;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class OrderRepositoryTest extends TestCase
{
private PDO $pdo;
private OrderRepository $repository;
protected function setUp(): void
{
parent::setUp();
// In-memory SQLite: fresh database per process, no cleanup needed
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create schema fresh each test run
$this->pdo->exec('
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
total REAL NOT NULL,
status TEXT NOT NULL DEFAULT "pending",
created_at TEXT NOT NULL
)
');
$this->repository = new OrderRepository($this->pdo);
}
protected function tearDown(): void
{
// SQLite in-memory is automatically destroyed when connection closes
// For a persistent DB, you would use transactions:
// $this->pdo->rollBack();
unset($this->pdo, $this->repository);
parent::tearDown();
}
#[Test]
public function it_saves_and_retrieves_an_order(): void
{
$order = new Order(userId: 1, total: 59.99, status: 'pending');
$this->repository->save($order);
$found = $this->repository->findByUserId(1);
$this->assertCount(1, $found);
$this->assertSame(59.99, $found[0]->total);
}
#[Test]
public function it_returns_empty_array_when_no_orders_exist(): void
{
// Isolation: previous test's data is gone — new in-memory DB
$result = $this->repository->findByUserId(999);
$this->assertSame([], $result);
}
#[Test]
public function it_filters_by_status(): void
{
$this->repository->save(new Order(userId: 1, total: 10.0, status: 'pending'));
$this->repository->save(new Order(userId: 1, total: 20.0, status: 'completed'));
$pending = $this->repository->findByStatus('pending');
$this->assertCount(1, $pending);
$this->assertSame('pending', $pending[0]->status);
}
}
// Transaction-based isolation pattern (for persistent databases)
abstract class DatabaseTestCase extends TestCase
{
protected PDO $pdo;
protected function setUp(): void
{
parent::setUp();
$this->pdo = $this->createConnection(); // real DB connection
$this->pdo->beginTransaction(); // wrap in transaction
}
protected function tearDown(): void
{
$this->pdo->rollBack(); // undo all changes — no truncation needed
parent::tearDown();
}
abstract protected function createConnection(): PDO;
}Interview Q&A
Q: What is the difference between setUp() and setUpBeforeClass(), and when is each appropriate?
setUp() is an instance method that runs before each individual test method—it's the right place for all mutable state initialization: creating objects, opening database connections, seeding minimal test data. setUpBeforeClass() is a static method that runs once before any tests in the class—it's appropriate for expensive, immutable resources like compiling a schema, creating a database table structure, or launching a server process. The danger with setUpBeforeClass() is that all tests in the class share the same state created there; if any test mutates it (even accidentally), subsequent tests see dirty state. Use setUpBeforeClass() only for truly read-only setup.
Q: How do database transactions provide test isolation, and what are their limitations?
Wrapping each test in a transaction that rolls back in tearDown() ensures that inserts, updates, and deletes from one test are never committed to the database, so subsequent tests see a clean state. This is much faster than truncating tables (no disk I/O, no auto-increment reset) and avoids schema recreation overhead. The limitations: (1) if the code under test starts its own transaction, the rollback behavior depends on whether the database supports nested transactions (SQLite doesn't; PostgreSQL uses savepoints). (2) Tests cannot test commit behavior itself. (3) Different database connections (read replica, queue worker process) won't see the uncommitted data, breaking tests that check cross-connection state.
Q: What are the risks of using static properties in test classes, and how do you prevent state leakage between test classes?
PHPUnit reuses the same class-level static state across all test methods within a class, and in some configurations across classes in the same process. If a test sets a static property on a service class (like a static registry or cache), subsequent tests in a different class will see that mutated state. The fix is threefold: (1) avoid static mutable state in application code entirely—prefer instance state or explicit injection, (2) use --process-isolation to run each test class in a separate process (expensive but reliable), (3) explicitly reset known static state in tearDownAfterClass(). The most pragmatic approach is to treat static state as a code smell and refactor it away.