levelmax World 4 Testing Properly
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 4.4 ~15 min Target: PHPStan max

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 2025green
The test, every day sincered
Nobody touched the code. The calendar did.

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

Before
<?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]));
    }
}
After
<?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]));
    }
}

Quick check

1. A test builds dates with new DateTimeImmutable('+3 days') and passes against the real clock. What's the risk?
2. Your code shuffles a deck with an injected Random\Randomizer. What should its test assert?
3. Why freeze the clock at 09:30 rather than 00:00?

CHALLENGE

RenewalReminders (read only) finds the subscriptions renewing in the next seven days. Its test passed when it was written, in March 2025, and has failed every day since, because it asks the real clock what day it is.

In tests/RenewalRemindersTest.php, write a clock that always gives the same time, and test around that moment until your tests catch every planted bug.

REFERENCE
final class FrozenClock implements Clock
{
    public function __construct(private readonly DateTimeImmutable $now) {}

    public function now(): DateTimeImmutable
    {
        return $this->now;
    }
}

new RenewalReminders(new FrozenClock(new DateTimeImmutable('2026-03-10 09:30')));
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.