Test Architecture: Fixtures, Factories, and Deterministic Tests
Design a test suite's supporting structure — fixtures, factories, deterministic inputs, a clock abstraction, isolation, and cleanup — so tests are fast, reliable, and independent of execution order or the wall clock.
Individual assertions are only half of what makes a test suite trustworthy. The other half is architecture: how test data gets created, whether a test depends on anything outside its own control (the real clock, a shared database row, another test having already run), and whether it leaves the world exactly as it found it. A test suite with weak architecture produces the worst kind of bug report — a test that fails only sometimes, or only in CI, or only after 11:58 PM, or only when run in a particular order. This lesson covers the five pillars of solid test architecture: fixtures, factories, determinism, clock abstraction, and isolation with cleanup.
Fixtures: reusable, known test setup
A fixture is a piece of test setup data or state prepared ahead of an assertion — a known-good User object, a pre-populated in-memory array, a small SQLite database seeded with a handful of rows. PHPUnit gives fixtures a formal home in setUp() (runs before every test method in the class) and tearDown() (runs after every test method), plus their static, once-per-class counterparts setUpBeforeClass()/tearDownAfterClass() for expensive shared setup that's safe to reuse read-only across tests.
The goal of a fixture is to give every test a known starting point without repeating the same ten lines of setup code in every method. But fixtures can be overused: a setUp() that builds five unrelated objects because *some* tests in the class need one of them makes every test slower and, worse, makes it unclear which setup actually matters for a given assertion. A good fixture is minimal and obviously relevant to the tests that use it — if only two of eight test methods need a particular object, build it inside those two methods instead of in shared setUp().
• Fixture = known, reusable test setup data or state. • PHPUnit: setUp()/tearDown() run around every test; setUpBeforeClass()/tearDownAfterClass() run once per class. • Keep fixtures minimal and relevant — don't build objects in setUp() that only one test needs. • Fixtures should represent a known, understandable starting state, not incidental leftover data.
Factories: generating realistic objects on demand
A factory is a small class or function whose only job is constructing a fully-valid object for tests, with sensible defaults for every field and the ability to override just the fields a specific test cares about. Where a fixture is often a single fixed object reused as-is, a factory produces a fresh object per call — useful when a test needs several similar-but-distinct objects (three users with different roles, ten orders with different totals) without hand-writing each one.
Libraries like fakerphp/faker generate realistic-looking random data (names, emails, addresses) for fields a test doesn't care about, while the factory's explicit parameters control the fields the test does care about. This combination — random-but-valid for irrelevant fields, explicit for relevant ones — keeps tests readable (only the fields that matter to the assertion appear in the test) while still producing realistic, varied data that catches assumptions baked into code (like assuming every name is short, or every email is lowercase).
• Factory = a builder that produces a fully-valid object with overridable fields. • Faker (fakerphp/faker) fills in irrelevant fields with realistic random data. • Only override the fields your specific test actually cares about — that's what makes the test readable. • Factories reduce duplication versus hand-constructing similar objects in every test method.
Deterministic tests: no real randomness, no real 'now', no real network
A deterministic test produces the same result every single time it runs, regardless of when, where, or how many times it's run. The three most common sources of non-determinism in PHP tests are: real randomness (rand(), random_int(), Faker without a fixed seed), the real wall clock (time(), new DateTime('now')), and real network calls (hitting a live API, a live payment gateway, a live third-party service). Each of these makes a test's outcome depend on something the test itself doesn't control, which means the test can fail — or worse, silently pass when it shouldn't — for reasons that have nothing to do with the code under test.
The fix in each case is the same shape: inject the non-deterministic thing instead of calling it directly, so a test can substitute a fixed, predictable value. Random values get a fixed seed or an injected random source; network calls get mocked HTTP clients or fakes; and — the case important enough to get its own section — the current time gets a clock abstraction instead of a direct call to new DateTime('now').
• Determinism = same test, same result, every run, everywhere. • Non-determinism sources: real randomness, real wall-clock time, real network calls. • Fix: inject these dependencies so tests can substitute fixed, predictable values. • A flaky test (passes sometimes, fails sometimes, same code) is almost always hiding one of these three.
Clock abstraction: never call new DateTime('now') directly in logic
Any production code that needs "the current time" should ask for it through an injected Clock interface rather than calling new DateTime('now'), time(), or date() directly. In tests, a FrozenClock (or FixedClock) implementation returns a fixed, chosen instant every time it's asked, so a test that checks "is this subscription expired" or "is this discount still valid" can set the clock to any moment — including deliberately awkward ones like December 31st 23:59:59, a DST transition, or a leap day — without waiting for that moment to actually occur.
PHP 8.x doesn't ship a Clock interface in the standard library the way some other languages do, but the pattern is simple enough to hand-roll (a one-method interface returning a DateTimeImmutable), and PSR-20 (the Clock interface) standardizes exactly this shape so libraries and applications can share one convention. The key discipline is: production code never constructs "now" itself — it always receives a clock and asks the clock what time it is.
• Never call new DateTime('now')/time() directly inside logic that tests need to control. • Inject a Clock interface; production code asks the clock for the time. • Tests use a FrozenClock/FixedClock set to any instant, including edge cases (midnight, DST, leap day). • PSR-20 standardizes a Clock interface with a single now(): DateTimeImmutable method.
Isolation and cleanup: no test should depend on another test
Test isolation means each test method can run alone, in any order, and get the same result. A test suite breaks isolation when tests share mutable state — a static property that one test sets and another reads, a database row that test A inserts and test B assumes exists, a file left on disk by a previous run. Isolation failures are notoriously hard to debug because the failing test looks fine in isolation (phpunit --filter testB passes) and only fails as part of the full suite, or only in a specific run order — exactly the kind of bug that erodes trust in the whole suite.
Cleanup is isolation's other half: whatever a test creates, it should remove — database rows inserted during the test, temporary files written to disk, cache entries set, global/static state mutated. PHPUnit's tearDown() is the conventional place for this, and wrapping database tests in a transaction that's rolled back at the end of each test (rather than committed) is a common, fast pattern for keeping a real database clean between tests without manually deleting rows one by one.
• Isolation: any test can run alone or in any order and produce the same result. • Shared mutable state (statics, leftover DB rows, files) is the usual cause of isolation failures. • Cleanup: tear down everything a test created — rows, files, cache entries, static/global state. • Wrapping DB tests in a rolled-back transaction is a fast, reliable cleanup pattern.
A Clock interface and a frozen implementation for tests
Production code (and its container wiring) uses SystemClock. Tests construct a FrozenClock with any instant they want to simulate, including edge cases. The interface has one method, so implementing a test double costs almost nothing.
<?php
declare(strict_types=1);
interface Clock
{
public function now(): DateTimeImmutable;
}
final class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('now');
}
}
final class FrozenClock implements Clock
{
public function __construct(private readonly DateTimeImmutable $fixed) {}
public function now(): DateTimeImmutable
{
return $this->fixed;
}
}Code Examples
The clock is frozen to a specific, chosen instant, so the test result never depends on when it actually runs — no flakiness near midnight or across a DST change. The factory builds a fully-valid User with only the id field explicitly controlled, keeping the test focused on what actually matters to the assertion (subscription expiry), not on constructing a user by hand.
$clock = new FrozenClock(new DateTimeImmutable('2026-03-15 12:00:00'));
Common mistake: Calling new DateTimeImmutable('now') inside Subscription::isExpired() instead of accepting an injected clock would make this exact test flaky: it would only reliably pass while the wall clock genuinely sits between the two dates, and would need to be rewritten every time those dates moved into the past.
RequestCounter holds mutable state in a static property, which persists across test methods within the same PHP process unless something resets it. Resetting the static state in setUp() (which runs before every test method) restores isolation: each test starts from the same known state no matter what ran before it or in what order PHPUnit chooses to run the suite.
Common mistake: A test suite that only passes when run in a specific order (e.g. phpunit tests/RequestCounterTest.php alone fails, but the full suite passes) is a hard signal of a static-state isolation failure — never rely on execution order for correctness.
<?php
declare(strict_types=1);
final class UserFactory
{
public static function make(array $overrides = []): User
{
$faker = \Faker\Factory::create();
return new User(
id: $overrides['id'] ?? $faker->numberBetween(1, 100000),
name: $overrides['name'] ?? $faker->name(),
email: $overrides['email'] ?? $faker->unique()->safeEmail(),
);
}
}
final class SubscriptionExpiryTest extends \PHPUnit\Framework\TestCase
{
public function testSubscriptionIsExpiredAfterEndDate(): void
{
// Fixed instant instead of "now" — deterministic regardless of
// when this test actually executes.
$clock = new FrozenClock(new DateTimeImmutable('2026-03-15 12:00:00'));
// Factory: only the field this test cares about is overridden;
// everything else (name, email) is realistic filler from Faker.
$user = UserFactory::make(['id' => 42]);
$subscription = new Subscription(
user: $user,
endsAt: new DateTimeImmutable('2026-03-14 23:59:59'),
);
$this->assertTrue($subscription->isExpired($clock));
}
public function testSubscription
…<?php
declare(strict_types=1);
final class RequestCounter
{
private static int $count = 0;
public static function increment(): int
{
return ++self::$count;
}
public static function reset(): void
{
self::$count = 0;
}
}
final class RequestCounterTest extends \PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
parent::setUp();
// Cleanup at the start of every test guarantees isolation
// regardless of what ran before this test, or in what order.
RequestCounter::reset();
}
public function testFirstIncrementReturnsOne(): void
{
$this->assertSame(1, RequestCounter::increment());
}
public function testSecondIndependentIncrementAlsoReturnsOne(): void
{
// Without resetting in setUp(), this test would return 2 if it
// ran after testFirstIncrementReturnsOne(), and 1 if run alone
// — an isolation failure that depends on execution order.
$this->assertSame(1, RequestCounter::increment());
}
}Where each pillar sits in a single test's lifecycle
Fixtures/factories build the starting state, the clock is injected rather than read from the system, and cleanup in tearDown() restores isolation for the next test.
setUp() test method body tearDown()
─────── ───────────────── ──────────
build fixtures → call code under test → delete DB rows
(via factories) with injected Clock remove temp files
reset shared state assert exact expected reset static state
values (not just truthy)
Determinism guard: no real time(), no real rand(), no real network
anywhere inside the test method body.Important Rules
Build test data through fixtures/factories, not ad hoc inline object construction repeated across many tests.
Never let production logic call new DateTime('now')/time() directly — inject a Clock and control it in tests.
A test must produce the same result whether run alone or as part of the full suite, in any order.
Whatever a test creates (rows, files, cache entries, static state), it must clean up.
Treat any flaky test (passes sometimes, fails sometimes, unchanged code) as a determinism or isolation bug to fix, not something to retry until green.
Why this matters in production teams
Flaky tests are one of the top reasons teams stop trusting their CI pipeline — once a test is known to fail 'sometimes for no reason', developers start re-running failed builds instead of investigating, and real regressions start slipping through disguised as 'probably just flaky again'. The overwhelming majority of flaky PHP tests trace back to exactly the issues in this lesson: a real clock call buried in business logic, a static property left dirty from a previous test, or a factory that generates colliding random data (like two 'unique' emails from an unseeded Faker). Investing in clock injection, deterministic factories, and disciplined tearDown() cleanup early is far cheaper than untangling a large suite's flakiness later.
A test asserts a discount is 'expired' using if (time() > $endTimestamp) inside the production code, with no way to inject a different time.
Production code accepts an injected Clock and calls $clock->now(); tests pass a FrozenClock set to whatever instant they want to verify.
A test built around the real time() can only reliably test 'is expired' by picking a date already in the past, and can never deterministically test the exact boundary instant or edge cases like a DST transition — the assertion's truth silently depends on when the test happens to run, including flaking near midnight.
Why should production code depend on an injected Clock interface instead of calling new DateTime('now') directly? A. Because DateTime is deprecated in PHP 8.2 B. Because it lets tests substitute a fixed, chosen instant (including edge cases like midnight or DST) instead of depending on whatever moment the test happens to run C. Because DateTimeImmutable objects use less memory than a Clock interface D. Because PHPUnit refuses to run tests that call new DateTime('now')
B. Because it lets tests substitute a fixed, chosen instant (including edge cases like midnight or DST) instead of depending on whatever moment the test happens to run — Injecting a Clock lets a test control exactly what 'now' means during that test run, producing deterministic, repeatable results regardless of the real wall-clock time — including edge cases that would otherwise only be testable by waiting for them to actually occur.
[scenario] A test passes when run by itself but fails when the full suite runs. What is the most likely root cause? A. The test is checking floating-point equality B. A test isolation failure — shared mutable state (e.g. a static property or a leftover database row) from an earlier test is leaking into this one C. The test file has a syntax error D. PHPUnit has a bug and needs to be reinstalled
B. A test isolation failure — shared mutable state (e.g. a static property or a leftover database row) from an earlier test is leaking into this one — A test that behaves differently depending on what ran before it is the textbook signature of an isolation failure — some piece of state (static, database, filesystem, cache) wasn't reset between tests, so an earlier test's leftover state is influencing this one.
You have a PasswordResetToken class whose isExpired(): bool method currently does return new DateTime('now') > $this->expiresAt;. Refactor it to accept an injected Clock, then write two PHPUnit tests using a FrozenClock: one proving the token is expired one second after expiresAt, and one proving it is not yet expired at the exact expiresAt instant. Make sure a test class's static or database state (if any is added) is reset in setUp()/tearDown() so the tests pass in any order.
final class PasswordResetToken { public function __construct(private readonly DateTimeImmutable $expiresAt) {} public function isExpired(Clock $clock): bool { return $clock->now() > $this->expiresAt; } } final class PasswordResetTokenTest extends \PHPUnit\Framework\TestCase { public function testExpiredOneSecondAfter(): void { $expiresAt = new DateTimeImmutable('2026-01-01 00:00:00'); $token = new PasswordResetToken($expiresAt); $clock = new FrozenClock($expiresAt->modify('+1 second')); $this->assertTrue($token->isExpired($clock)); } public function testNotExpiredAtExactBoundary(): void { $expiresAt = new DateTimeImmutable('2026-01-01 00:00:00'); $token = new PasswordResetToken($expiresAt); $clock = new FrozenClock($expiresAt); $this->assertFalse($token->isExpired($clock)); } }
Change the method signature to isExpired(Clock $clock): bool (or inject the Clock via the constructor) and replace new DateTime('now') with $clock->now(). Build each test's FrozenClock from a DateTimeImmutable exactly at, and one second after, expiresAt.