Time, randomness and I/O
The renewal reminders test passed on 10 March 2025, the day it was written. It has failed every day since. Nothing in the code changed. The test asked the real clock what day it was, and the answer kept changing.
Some tests are worse: they only fail at certain times. Tests that are red after 11pm, on the 31st, or the week the clocks go forward teach a team to press "re-run" without reading why.
Hidden inputs
Code that reads the clock, rolls a random number or touches the outside world has inputs that its signature doesn't show. The fix is the same each time: make the input a dependency, so a test can supply its own.
| Hidden input | Make it a dependency | In the test |
|---|---|---|
new DateTimeImmutable() |
A Clock, like the one from 3.5 |
A FrozenClock set to a fixed moment |
random_int(), shuffle() |
A Random\Randomizer passed in |
new Randomizer(new Mt19937(42)), the same sequence every run |
| Files, HTTP, email | An interface you own: Outbox, ExchangeRates |
An in-memory fake, as in 4.3 |
final class FrozenClock implements Clock { public function __construct(private readonly DateTimeImmutable $now) {} public function now(): DateTimeImmutable { return $this->now; } }
With a seeded Randomizer, test what must hold for any outcome: a coupon code is eight characters from the right alphabet, and a shuffle keeps every card. Don't assert the exact sequence a seed happens to produce. That pins down PHP's random engine, not your code.
The real SystemClock and the real HTTP client still need to work. Keep them tiny, with no logic, so there's nothing in them to get wrong.
| The test, 10 March 2025 | green |
|---|---|
| The test, every day since | red |
Pick a realistic moment
A frozen clock still needs a sensible time. 2026-03-10 00:00 is a tempting choice, but midnight is the one moment when "now" and "the start of today" are the same. A bug that forgets to round to the start of the day passes at midnight and fails at breakfast. Freeze the clock mid-morning, and test the edges around it: today, the last day of a window, the day after it, and yesterday.
Power-up
<?php use PHPUnit\Framework\TestCase; final class RenewalRemindersTest extends TestCase { public function testTheLastDayOfTheWindowIsDue(): void { $reminders = new RenewalReminders(new SystemClock()); $lastDay = new Subscription('ada@example.com', new DateTimeImmutable('+7 days')); // '+7 days' keeps the time of day, so this only passes at exactly midnight. self::assertSame([$lastDay], $reminders->due([$lastDay])); } }
<?php use PHPUnit\Framework\TestCase; final class RenewalRemindersTest extends TestCase { public function testTheLastDayOfTheWindowIsDue(): void { $reminders = new RenewalReminders(new FrozenClock(new DateTimeImmutable('2026-03-10 09:30'))); $lastDay = new Subscription('ada@example.com', new DateTimeImmutable('2026-03-17')); self::assertSame([$lastDay], $reminders->due([$lastDay])); } }