artisanplaybook .dev
ARTICLES / TESTING

Time-dependent tests: why freezing the clock isn't optional

Any test that touches now(), Carbon::now(), or a duration calculation is implicitly coupled to whatever moment it happens to run — which is fine until that moment lands near a day boundary, a month with 28 days instead of 31, or a leap second, and a test that's never actually been wrong about the logic fails anyway. The fix isn't better assertions, it's controlling time explicitly instead of trusting whatever the system clock says when CI happens to run.

ON THIS PAGE

The bug that isn't a bug

public function test_trial_expires_after_fourteen_days(): void
{
    $user = User::factory()->create(['trial_ends_at' => now()->addDays(14)]);

    $this->assertFalse($user->trialHasExpired());
}

This looks solid, and it passes almost every time it runs — right up until it happens to execute at 11:59:58 PM, now() inside the test and now() inside trialHasExpired() land on different sides of midnight by a couple of seconds, and a duration comparison that's supposed to be false flips to true. Nothing about the trial logic is wrong. The test asked the real clock what time it was twice, a few milliseconds apart, and assumed both answers would agree.

This class of failure is worse than an obviously broken test because it's rare, unreproducible on demand, and looks exactly like a flaky CI runner rather than a real timing coupling — which means it often gets re-run and ignored rather than fixed.

Freeze time instead of reading it live

Laravel's testing helpers let you pin now() to an exact instant for the duration of a test, so every call to now() or Carbon::now() anywhere in the code under test — not just in your test file — returns the same fixed value:

public function test_trial_expires_after_fourteen_days(): void
{
    $this->travelTo(now()->startOfDay());

    $user = User::factory()->create(['trial_ends_at' => now()->addDays(14)]);

    $this->assertFalse($user->trialHasExpired());

    $this->travelTo(now()->addDays(15));

    $this->assertTrue($user->trialHasExpired());
}

travelTo(Carbon $time) moves to a specific instant. freezeTime() pins the clock to the current moment without changing it. travel($value)->days() (or ->hours(), ->minutes()) moves forward relative to now. All three eliminate the boundary problem entirely, because the test now controls exactly what "now" means rather than asking the OS clock and hoping it lands somewhere convenient.

The part that matters more than the syntax: automatic cleanup

The reason to prefer these helpers over calling Carbon::setTestNow() directly isn't just convenience — it's that Laravel's travel()/travelTo()/freezeTime() helpers automatically restore real time after the test finishes. Time frozen with the helper doesn't leak into the next test.

Calling Carbon::setTestNow() yourself doesn't come with that safety net:

public function test_something_time_sensitive(): void
{
    Carbon::setTestNow('2026-01-01 00:00:00');

    // ...assertions...

    // forgot to reset it — this test class is now "stuck" at Jan 1, 2026
    // for every subsequent test in the run, in this and possibly other files
}

Carbon::setTestNow() sets a static value that every subsequent now() call in the process will return, regardless of what test is currently running. If you use it directly, you're responsible for resetting it — typically Carbon::setTestNow() with no argument, in tearDown(). Miss that in one test class, and every test that runs afterward in the same process silently thinks it's a fixed date in the past, which produces exactly the kind of "why does this unrelated test suddenly fail" confusion that takes far longer to debug than the original flaky boundary case would have.

If you have a specific reason to reach for Carbon::setTestNow() directly rather than the travel helpers, pair it with an explicit tearDown():

protected function tearDown(): void
{
    Carbon::setTestNow();
    parent::tearDown();
}

But the simpler answer is almost always: use travelTo()/freezeTime() and get the cleanup for free.

Duration comparisons need a fixed reference point on both sides

A related trap: computing a duration using now() inside the assertion itself, rather than reusing a value from the arrange step of the test:

$user = User::factory()->create(['created_at' => now()->subDays(30)]);

// now() here is a slightly later moment than now() above —
// usually by microseconds, occasionally enough to matter near a boundary
$this->assertEquals(30, $user->created_at->diffInDays(now()));

Freezing time fixes this the same way — once now() is pinned for the whole test, both calls return the identical instant and the diff is exact rather than approximately right.

Testing scheduled jobs and expiry logic reliably

Anything that depends on "has enough time passed" — subscription renewals, cache expiry, a scheduled command that should only run on the first of the month — is exactly the category of logic that benefits most from this, since these are the tests most likely to have a real boundary somewhere nearby:

public function test_monthly_report_only_runs_on_first_of_month(): void
{
    $this->travelTo(Carbon::create(2026, 3, 1, 6, 0));

    Artisan::call('reports:monthly');

    Mail::assertSent(MonthlyReportMail::class);
}

public function test_monthly_report_does_not_run_on_other_days(): void
{
    $this->travelTo(Carbon::create(2026, 3, 15, 6, 0));

    Artisan::call('reports:monthly');

    Mail::assertNotSent(MonthlyReportMail::class);
}

Both tests are now deterministic regardless of what day they actually run in CI — the entire point of controlling time explicitly rather than depending on it.

Checklist before trusting a time-sensitive test

  • Does this test call now() or Carbon::now() more than once, expecting both calls to agree? If so, freeze time so they actually do.
  • Is the test near a real boundary (midnight, month-end, a fixed expiry window) where the real clock could occasionally land on the wrong side?
  • If time is set directly via Carbon::setTestNow() rather than travelTo()/freezeTime(), is there a tearDown() that resets it?
  • Would this test still pass if it happened to run at 11:59:59 PM instead of the middle of the day?

Related reading

ARCHIVE →