Making time-dependent code cleanly testable
Direct calls to new DateTime() or time() are the classic reason time-dependent logic is hard to test. A custom Clock abstraction fixes the problem at the root and lets you set fixed points in time inside PHPUnit tests.
Table of Contents
- 1. Why new DateTime() in code is a testing problem
- 2. Designing a custom Clock interface
- 3. The production implementation
- 4. A fixed test clock for PHPUnit
- 5. Practical example: testing a discount campaign at its validity boundary
- 6. The Symfony Clock Component as a ready-made solution
- 7. Migrating existing code to the Clock abstraction
- 8. Common pitfalls when working with the Clock abstraction
- 9. Conclusion: a small abstraction with a big impact
- 10. Summary
- 11. FAQ
1. Why new DateTime() in code is a testing problem
When a class calls new DateTime() or time() directly somewhere to determine the current moment, it couples itself inseparably to the real system clock. A test that wants to verify a discount code still works on the last valid day but no longer the day after cannot control that moment and must either wait for the real calendar or resort to ugly tricks like changing the server's system time.
This problem becomes especially visible in Magento projects with discount campaigns, special prices with a validity window, or cron job logic tied to weekdays or times of day. Without an abstraction, such tests either stay incomplete because the critical edge cases around validity boundaries are never checked, or they become fragile because they depend on the actual moment the test happens to run.
2. Designing a custom Clock interface
The solution follows a familiar pattern: instead of querying the system clock directly, you inject an abstraction that encapsulates exactly that. A minimal Clock interface generally needs only a single method that returns the current moment as DateTimeImmutable. This simplicity is deliberate, because an overly powerful interface with many methods undermines testability again.
It is important to consistently use DateTimeImmutable instead of the mutable DateTime. A mutable date that accidentally gets passed around and mutated somewhere in the code leads to the same hard-to-trace bugs as shared, mutable state in any other context. The immutable variant forces every change to produce a new object, which makes the data flow traceable.
<?php
declare(strict_types=1);
namespace App\Clock;
use DateTimeImmutable;
/**
* Abstraction for the current moment, replaces direct new DateTime() calls.
*/
interface ClockInterface
{
public function now(): DateTimeImmutable;
}
3. The production implementation
Production usage needs a simple implementation that actually returns the system clock. This class is deliberately kept trivial, its only purpose is to be wired up as the default implementation through the dependency injection configuration, while tests use a different implementation.
In a Magento context, you register this implementation as the default preference in di.xml, so that any class requesting ClockInterface through constructor property promotion automatically receives the system-clock implementation, without a single direct new DateTime() call remaining anywhere in application code.
<?php
declare(strict_types=1);
namespace App\Clock;
use DateTimeImmutable;
/**
* Returns the actual system time, default implementation for production use.
*/
final class SystemClock implements ClockInterface
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable();
}
}
4. A fixed test clock for PHPUnit
Tests need a second implementation that always returns the same, fixed, pre-configured moment. This class accepts the desired moment in its constructor and fully ignores the actual system clock. This lets every test set exactly the moment relevant to that test case, for instance exactly one second before a discount campaign expires.
It is also useful to add an extension that allows the internal time to be fast-forwarded deliberately during a test, for example to verify a cron job reacts correctly after a certain waiting period elapses. Such an advance() method makes time-progression tests possible without producing real waiting time during the test run.
<?php
declare(strict_types=1);
namespace Tests\Support\Clock;
use App\Clock\ClockInterface;
use DateTimeImmutable;
use DateInterval;
/**
* Fixed, manually controllable clock for PHPUnit tests.
*/
final class FrozenClock implements ClockInterface
{
public function __construct(private DateTimeImmutable $current)
{
}
public function now(): DateTimeImmutable
{
return $this->current;
}
public function advance(DateInterval $interval): void
{
$this->current = $this->current->add($interval);
}
}
5. Practical example: testing a discount campaign at its validity boundary
With FrozenClock, the case described at the start, whether a discount code still works on the last valid day but no longer the day after, can be verified exactly and without any real waiting time. You create a FrozenClock in setUp() or directly in the test, set to the desired moment, and inject it into the class under test.
The decisive advantage over waiting for the real calendar is that the test runs in a fraction of a second while still exactly covering the critical edge cases around the validity boundary, including the moment immediately before and immediately after the expiry point, which in practice is the most common source of off-by-one errors in time comparisons. The same technique also transfers to cron job logic, for instance verifying that a daily price reset kicks in exactly at midnight and not a minute later.
<?php
declare(strict_types=1);
namespace Tests\Unit\Discount;
use App\Discount\DiscountCodeValidator;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Tests\Support\Clock\FrozenClock;
final class DiscountCodeValidatorTest extends TestCase
{
public function testCodeIsValidOnLastDay(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2026-08-31 23:59:59'));
$validator = new DiscountCodeValidator($clock);
self::assertTrue($validator->isValid('SUMMER26', validUntil: '2026-08-31'));
}
public function testCodeIsInvalidOneSecondAfterExpiry(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2026-09-01 00:00:00'));
$validator = new DiscountCodeValidator($clock);
self::assertFalse($validator->isValid('SUMMER26', validUntil: '2026-08-31'));
}
}
6. The Symfony Clock Component as a ready-made solution
Anyone who prefers not to maintain the abstraction themselves can use the Symfony Clock Component, available as a standalone, framework-agnostic package since Symfony 6.2, which integrates cleanly into plain PHP or Magento projects. It offers a ClockInterface with the implementations NativeClock for production and MockClock for tests.
One advantage of the Symfony solution is the additional ClockSensitiveTrait, which lets you set a global test clock through ClockSensitiveTrait right in the test class, without manually passing the clock through every involved class. For larger projects with many time-dependent services, that can noticeably reduce wiring effort, at the cost of somewhat less explicit dependencies.
<?php
declare(strict_types=1);
namespace Tests\Unit\Discount;
use App\Discount\DiscountCodeValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\ClockSensitiveTrait;
use Symfony\Component\Clock\MockClock;
final class DiscountCodeValidatorSymfonyClockTest extends TestCase
{
use ClockSensitiveTrait;
public function testCodeIsInvalidAfterExpiry(): void
{
$clock = self::mockTime(new MockClock('2026-09-01 00:00:00'));
$validator = new DiscountCodeValidator($clock);
self::assertFalse($validator->isValid('SUMMER26', validUntil: '2026-08-31'));
}
}
7. Migrating existing code to the Clock abstraction
In a grown project, the migration works best step by step: you first search specifically for direct calls to new DateTime(), new DateTimeImmutable(), and time() inside business logic, while deliberately excluding technical infrastructure such as logging timestamps, since not every time call in a project is relevant to testing.
For every occurrence found, you check whether the class already receives dependencies through constructor property promotion. In that case, it is enough to add ClockInterface as another dependency and replace the direct time call with $this->clock->now(). For older classes without dependency injection, the switch is usually worth doing alongside a modernization that was already planned anyway.
8. Common pitfalls when working with the Clock abstraction
A common mistake is setting the FrozenClock in the test constructor but accidentally reusing it across multiple test methods of the same class, for instance as a property initialized only once in a static setup method. That lets shared, mutable state creep in between otherwise independent tests, and the order of test execution can suddenly affect the outcome. The FrozenClock should therefore consistently be created fresh in every individual test method or in setUp().
A second pitfall involves time zones: if the FrozenClock is created without an explicit time zone, it inherits PHP's default configured time zone, which can differ between a local development environment, a CI runner, and a production server. For tests that need to verify time zone edge cases such as the switch from daylight saving to standard time, the time zone must therefore always be specified explicitly in the DateTimeImmutable constructor instead of relying on an implicit environment setting.
9. Conclusion: a small abstraction with a big impact
The Clock abstraction is one of those patterns that looks trivial at first glance but in practice solves one of the most persistent testability problems in Magento and other PHP projects. Instead of artificially slowing tests down or leaving edge cases around validity boundaries incompletely tested, a single interface abstraction makes every point in time controllable.
Whether you write a lean custom implementation or rely on the Symfony Clock Component depends mostly on how many time-dependent services exist in the project and whether the extra convenience features like global time mocking are worth the price of an additional dependency.
| Approach | Production implementation | Test implementation | Notable trait |
|---|---|---|---|
| Custom ClockInterface | SystemClock with new DateTimeImmutable() | FrozenClock with a fixed moment | Full control, no extra dependency |
| Symfony Clock Component | NativeClock | MockClock | Ready-made solution, extra Composer package |
| ClockSensitiveTrait | Real system time | Globally mocked time in the test | No manual passing of the clock needed |
| Direct time() call | Current Unix timestamp | Not controllable | Should be avoided in business logic |
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
Clock Abstraction: The Key Facts at a Glance
Core problem
Direct new DateTime() or time() calls couple code inseparably to the real system clock.
Solution
A lean ClockInterface with exactly one now() method makes points in time injectable.
Testing benefit
A FrozenClock or MockClock allows exact testing of validity boundaries without real waiting time.
Ready-made option
The Symfony Clock Component offers NativeClock and MockClock without a custom implementation.