Skip to content

date: 2026-06-13 tags: [testing, time, clock, ci, phpstan, faketime] status: active graduated_to: .claude/rules/testing.md

A test that reads the real clock is a time-bomb — enforce frozen time, don't just ask for it

SymptomTaskProcessorJobTest went red overnight with no code change: a test asserting "priority 1" got "4". It had a hardcoded due => '2026-06-12' and never froze the clock. While the real date was ≤ 2026-06-12 the task read as "not overdue" and the test passed; from 2026-06-13 it read as "overdue", the job correctly forced p1 (Todoist 4), and the assertion broke. A time-bomb that turns CI red on every PHP PR once real time passes the hardcoded date (bug #488).

Root cause — the test didn't read the clock itself; the production code did (TaskProcessorJob computes overdue from now()), and the test left now() on the real wall clock. So a lint scanning test files for now() would see nothing wrong. The danger class is "a test exercises clock-reading production code without pinning the clock", not "a test calls now()".

Fix — per-test explicit Carbon::setTestNow() in every affected arrange (#489). Crucially not a global/root freeze: a hidden default now lets a test pass without declaring the time it assumes, which is the same opacity in a new costume (the maintainer rejected it explicitly; the community — Jest enableGlobally, Python autouse-freeze — treats it as an anti-pattern for the same reason).

Guard — two gates, each a different mechanism, graduated into .claude/rules/testing.md:

  1. Preventionphpstan-disallowed-clock.neon bans ambient now() / today() / Carbon::now() / Carbon::today() in app/ (existing sites grandfathered in phpstan-clock-baseline.neon; new ones fail PHPStan). Forces the reference time to be passed in — the isOverdue($on) pattern php-laravel.md already prescribes — so tests control it.
  2. Detection — a time-travel CI job (tests.yml) runs the suite ~3 years ahead via libfaketime. Any test that reads the clock without freezing flips red; an explicit setTestNow overrides the OS clock and is immune. This catches reads through baselined production code that the lint can't, and flushes latent bombs suite-wide. Non-blocking until the suite is clean under it, then a gate.

A faketime time-travel run is the only mechanism that catches the indirect case (test → unfrozen → production now()); the lint catches the direct case and stops the cause from spreading. Neither alone is sufficient — this is why both ship together.