Testing non-deterministic code reproducibly
Tests that depend on the current time, randomly generated UUIDs, or pseudo-random values are flaky: they sometimes fail and sometimes don't, and the cause is hard to reproduce. Clock interfaces, UUID factories, and controlled randomness solve this problem at its root, without complicating the production code.
Table of Contents
- 1. The problem with non-deterministic code in tests
- 2. Controlling time: the Clock interface pattern
- 3. Frozen Clock: freezing time in a test
- 4. Making UUIDs deterministic in tests
- 5. Controlling pseudo-randomness: mt_rand and random_int
- 6. Identifying and fixing flaky tests
- 7. Symfony Clock component and PSR-20
- 8. Controlling time and UUIDs in Magento tests
- 9. Approaches compared
- 10. Summary
- 11. FAQ
1. The problem with non-deterministic code in tests
A test is deterministic if it produces the same result every time given the same input. Non-deterministic tests, also called "flaky tests", sometimes fail and sometimes don't. The most common sources of non-determinism in PHP tests are: the current time (new DateTime(), time(), Carbon::now()), randomly generated UUIDs or IDs, pseudo-random values (rand(), mt_rand(), random_int()), and database operations that set timestamp-based fields.
The problem with flaky tests isn't that they occasionally fail, that's actually the best-case outcome, because it's at least a signal. The real problem is that tests depending on uncontrolled time or random values can sometimes pass green even though a regression was introduced. A test that checks whether an expiry date lies 30 days in the future will produce different results depending on the time of day, if time isn't controlled. The solution doesn't lie in test design but in the design of the production code: encapsulate non-deterministic state behind interfaces that can be replaced with controlled implementations in the test.
2. Controlling time: the Clock interface pattern
The Clock interface pattern is the established solution for time-dependent PHP code. Instead of calling new DateTime() or time() directly, a class takes a ClockInterface implementation as a dependency and retrieves the current time through it. In production code, a real system clock is injected. In the test, a "frozen clock" is injected that always returns the same, predefined timestamp.
Since PSR-20 (Clock Interface), there's a PHP standard for this pattern: the Psr\Clock\ClockInterface defines a single method, now(): DateTimeImmutable. Symfony 6.2+ ships a complete implementation of this standard in the symfony/clock package, including a MockClock for tests. The pattern is simple, powerful, and can be implemented without any framework dependency.
<?php
// ClockInterface and system clock implementation
declare(strict_types=1);
namespace Mironsoft\Common\Clock;
use Psr\Clock\ClockInterface;
/**
* System clock implementation, returns the real current time.
* Use this in production via dependency injection.
*/
final class SystemClock implements ClockInterface
{
/**
* Returns the current time as an immutable DateTimeImmutable.
*/
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
}
}
// Usage in a service that calculates expiry dates
final class SubscriptionService
{
public function __construct(
private readonly ClockInterface $clock,
private readonly SubscriptionRepositoryInterface $repository,
) {}
/**
* Creates a new subscription expiring 30 days from now.
*/
public function createMonthlySubscription(int $customerId): Subscription
{
$now = $this->clock->now();
$expiresAt = $now->modify('+30 days');
return $this->repository->save(
new Subscription(
customerId: $customerId,
startedAt: $now,
expiresAt: $expiresAt,
)
);
}
}
The crucial point: new DateTime() does not appear anywhere in the production code. All time-dependent code uses the injected ClockInterface. That makes time-dependent code fully testable, without mocking PHP's built-in functions or manipulating global state.
3. Frozen Clock: freezing time in a test
A frozen clock is a test implementation of ClockInterface that always returns the same predefined timestamp. The simplest implementation only needs a few lines of PHP:
<?php
// Test/Unit/Clock/FrozenClock.php: deterministic clock for tests
declare(strict_types=1);
namespace Mironsoft\Common\Test\Unit\Clock;
use Psr\Clock\ClockInterface;
/**
* Frozen clock for testing, always returns the same predefined time.
* Inject this instead of SystemClock in unit tests.
*/
final class FrozenClock implements ClockInterface
{
private \DateTimeImmutable $frozenAt;
public function __construct(string $dateTime = '2026-01-15 12:00:00', string $timezone = 'UTC')
{
$this->frozenAt = new \DateTimeImmutable($dateTime, new \DateTimeZone($timezone));
}
/**
* Returns the frozen time, always the same, never changes.
*/
public function now(): \DateTimeImmutable
{
return $this->frozenAt;
}
/**
* Advances the frozen time by the given interval, useful for testing sequences.
*/
public function advance(\DateInterval $interval): void
{
$this->frozenAt = $this->frozenAt->add($interval);
}
}
// SubscriptionServiceTest.php: using FrozenClock
final class SubscriptionServiceTest extends TestCase
{
public function testSubscriptionExpiresAfter30Days(): void
{
$clock = new FrozenClock('2026-01-15 12:00:00');
$repoMock = $this->createMock(SubscriptionRepositoryInterface::class);
$repoMock->expects(self::once())
->method('save')
->with(self::callback(function (Subscription $sub) {
// Deterministic: always '2026-01-15' + 30 days = '2026-02-14'
self::assertEquals(
new \DateTimeImmutable('2026-02-14 12:00:00', new \DateTimeZone('UTC')),
$sub->expiresAt
);
return true;
}))
->willReturnArgument(0);
$service = new SubscriptionService($clock, $repoMock);
$service->createMonthlySubscription(42);
}
public function testExpiredSubscriptionIsDetectedCorrectly(): void
{
$clock = new FrozenClock('2026-03-01 00:00:00');
// Subscription that expired on Feb 14, clock is now March 1
$subscription = new Subscription(
customerId: 42,
startedAt: new \DateTimeImmutable('2026-01-15'),
expiresAt: new \DateTimeImmutable('2026-02-14'),
);
$checker = new SubscriptionExpiryChecker($clock);
self::assertTrue($checker->isExpired($subscription));
}
}
4. Making UUIDs deterministic in tests
UUIDs (Universally Unique Identifiers) are, by definition, randomly generated identifiers. In tests that rely on UUIDs in database IDs, event payloads, or audit logs, that's a problem: every test run produces different UUIDs, which makes it impossible to test exact values. The solution follows the same pattern as the Clock interface: a UuidGeneratorInterface abstracts UUID generation; a deterministic implementation is injected in the test.
The deterministic UUID implementation for tests returns pre-programmed UUIDs from an internal list, the first UUID for the first call, the second for the second call, and so on. That lets tests verify exactly which UUID was assigned to which object. Alternatively: a sequential UUID (00000000-0000-0000-0000-000000000001, ...0002, etc.) makes tests readable and reproducible without needing a full list of pre-programmed UUIDs.
<?php
// UUID abstraction for testable code
declare(strict_types=1);
namespace Mironsoft\Common\Identity;
/**
* Interface for UUID generation, injectable and mockable in tests.
*/
interface UuidGeneratorInterface
{
/**
* Generates and returns a new UUID string.
*/
public function generate(): string;
}
/**
* Production implementation using ramsey/uuid.
*/
final class RamseyUuidGenerator implements UuidGeneratorInterface
{
public function generate(): string
{
return \Ramsey\Uuid\Uuid::uuid4()->toString();
}
}
/**
* Test implementation, returns sequential UUIDs for deterministic tests.
*/
final class SequentialUuidGenerator implements UuidGeneratorInterface
{
private int $counter = 0;
public function generate(): string
{
$this->counter++;
return sprintf('00000000-0000-0000-0000-%012d', $this->counter);
}
}
// Test using SequentialUuidGenerator
final class OrderCreationServiceTest extends TestCase
{
public function testCreatesOrderWithDeterministicId(): void
{
$uuidGen = new SequentialUuidGenerator();
$service = new OrderCreationService($uuidGen);
$order1 = $service->createOrder(['sku' => 'test-001', 'qty' => 1]);
$order2 = $service->createOrder(['sku' => 'test-002', 'qty' => 2]);
// Deterministic: first call always returns ...000000000001
self::assertSame('00000000-0000-0000-0000-000000000001', $order1->getId());
self::assertSame('00000000-0000-0000-0000-000000000002', $order2->getId());
}
}
5. Controlling pseudo-randomness: mt_rand and random_int
Pseudo-random functions like mt_rand() and random_int() are harder to control than time and UUIDs, because PHP offers no built-in mechanism to set the seed for random_int() (which would itself be a security problem). The solution is the same as for UUIDs: encapsulate random values behind an interface and replace it with a deterministic implementation in the test.
For tests that need to verify the system's behavior for specific random values, the stub approach is the simplest: a RandomnessInterface implementation returns pre-programmed values from a list. For tests that only need to verify the system works correctly with arbitrary but valid random values, it's enough to set a fixed seed via mt_srand() and use mt_rand(), which produces deterministic sequences that are identical across test runs.
6. Identifying and fixing flaky tests
Identifying flaky tests is often harder than fixing them. PHPUnit itself has no built-in detection for time-dependent tests. The best strategy: run tests repeatedly and check for differing results. The PHPUnit flag --repeat=10 runs every test ten times. If a test fails in one out of ten runs, it's flaky. Alternatively: run tests shortly after midnight or shortly before a month boundary, that surfaces many time-dependent bugs.
Once flaky tests are identified, the fix is always the same: refactor the production code to encapsulate the non-deterministic state behind an interface, then inject the deterministic test implementation in the test. There is no reasonable alternative: attempts to stabilize flaky tests with sleep() calls or tolerance ranges don't solve the problem, they only hide it and lengthen the test runtime.
7. Symfony Clock component and PSR-20
Symfony 6.2 introduced the symfony/clock component, which contains a complete PSR-20 implementation. The NativeClock class is the production implementation; MockClock is the test implementation with additional helper methods for time-based tests. The advantage of symfony/clock: the library is well tested, actively maintained, and integrates seamlessly into Symfony projects as well as other frameworks like Laravel that support PSR interfaces.
PSR-20 standardizes the Clock interface across all PHP frameworks. That means: a library using PSR-20 can be used in Symfony, Laravel, Magento, and any other PSR-compatible framework without needing different Clock interfaces implemented for different frameworks. For Magento projects that use Symfony components alongside it, PSR-20 is the cleanest integration foundation.
8. Controlling time and UUIDs in Magento tests
Magento 2 internally uses $this->dateTime->gmtDate() and similar helpers for time-related operations in some places. These classes aren't PSR-20-compatible and are hard to mock. For custom modules, the recommendation is clear: never use Magento's internal date helpers directly in your own services. Instead, implement a PSR-20 ClockInterface that either wraps the Magento helpers or uses PHP's built-in functions directly.
For Magento integration tests, where the time comes from the database or is stored in database fields: the Magento fixture system doesn't allow direct control over time. The solution here is more pragmatic: generate test data with explicitly set timestamps (via the repository) instead of relying on "now". Setting an expiry date to 2020-01-01 is deterministic and clearly documents the test's intent.
| Problem source | Wrong approach | Correct approach | Test double |
|---|---|---|---|
| Current time | new DateTime() directly |
Inject ClockInterface | FrozenClock / MockClock |
| UUID generation | Uuid::uuid4() directly |
UuidGeneratorInterface | SequentialUuidGenerator |
| Random values | random_int() directly |
RandomnessInterface | StubRandomness (predefined list) |
| Token generation | bin2hex(random_bytes()) directly |
TokenGeneratorInterface | FixedTokenGenerator |
| Dates in fixtures | Carbon::now() in fixtures | Explicit date values | FrozenClock in integration tests |
9. Approaches compared
There are several approaches to controlling non-deterministic code in PHP tests. The cleanest method is the interface pattern: all non-deterministic operations are abstracted behind interfaces that get replaced with deterministic implementations in the test. This approach requires upfront refactoring of the production code, but leads to a permanently cleaner architecture.
A faster but less clean alternative for existing code: overriding static methods with runkit or namespace monkey-patching. That's a hack that isn't recommended for new code, but it can serve as a bridging strategy for legacy code without interface abstraction. A third option: assertions that accept tolerance ranges, such as assertEqualsWithDelta() for timestamps. That makes tests more robust against timing fluctuations, but doesn't solve the actual problem, the tests remain non-deterministic, just less flaky.
10. Summary
Deterministic testing of time, UUIDs, and random values isn't a testing problem, it's an architecture problem. The solution doesn't lie in test design but in the design of the production code: non-deterministic state is abstracted behind interfaces. In production code, real implementations are injected. In the test, deterministic test doubles are injected. The result is reproducible, reliable, and understandable test behavior.
The PSR-20 Clock interface is the current standard for time-based abstraction in PHP. symfony/clock is the mature library that implements this standard. For UUIDs, the interface pattern is just as clean and easy to implement. Flaky tests that depend on non-deterministic state are a direct signal that this state hasn't yet been sufficiently abstracted, making them an architecture critique as well.
Controlling Time, UUIDs, and Randomness in Tests: The Essentials at a Glance
PSR-20 Clock Interface
Never new DateTime() directly. Always inject ClockInterface. FrozenClock for tests. symfony/clock is the mature library for it.
UUID abstraction
UuidGeneratorInterface wraps Uuid::uuid4(). SequentialUuidGenerator in the test returns ...0001, ...0002, etc., deterministic and readable.
Fixing flaky tests
Flaky tests are architecture critique. No sleep(), no tolerance ranges as a fix. Refactor the production code to encapsulate the non-deterministic state.
Magento specifics
Don't use Magento's internal date helpers in your own services. Use explicit date values in fixtures instead of "now". Use PSR-20 for all new modules.
11. FAQ: Controlling Time, UUIDs, and Randomness in PHPUnit Tests
1What are flaky tests and why do they happen?
time(), new DateTime(), random UUIDs, or random_int() without interface abstraction.2What is the Clock interface pattern?
new DateTime() directly. Production code: SystemClock. Test: FrozenClock with a predefined timestamp. PSR-20 standardizes the interface.3What is PSR-20?
now(): DateTimeImmutable. Cross-framework compatible, usable in Symfony, Laravel, and Magento without adjustment.4Making UUID generation deterministic?
...0001, ...0002, reproducible and verifiable in assertions.5Identifying flaky tests?
--repeat=N runs tests N times. Running tests after midnight or before a month boundary surfaces time-dependent bugs. CI trend tracking reveals tests with fluctuating results over time.6Can sleep() stabilize flaky tests?
7Controlling random_int() in tests?
random_int(). In the test: StubRandomness with pre-programmed values. Or: mt_srand(42) + mt_rand() for deterministic sequences.8What is symfony/clock?
9Setting timestamps deterministically in Magento test fixtures?
'2020-01-01' instead of Carbon::now(). Via a repository with explicitly set timestamps. FrozenClock via di.xml in the test context.