Code that calls datetime.now() directly is code whose tests must either wait for time to pass or reach into the standard library and replace it. Both are bad trades: waiting makes tests slow and flaky, and patching datetime — directly or through freezegun — changes the clock for everything in the process, including logging, database drivers and anything else that happens to ask what time it is. An injected clock replaces both with something simpler: the code asks a clock object for the time, production passes the real one, and tests pass one they control.
The change is small in the code and large in the tests. Expiry, scheduling, rate limiting, token lifetimes and retry backoff all become testable in microseconds with assertions that read like the specification: advance an hour and one second, and the token is no longer valid.
The cost is modest and mostly one-off. The protocol and both implementations are thirty lines. Each service that reads time gains a constructor parameter with a default, so existing callers are unaffected. The ongoing discipline is simply that new code asks its clock rather than calling datetime.now() directly — something a lint rule banning bare datetime.now and time.time outside the SystemClock class can enforce mechanically, so the pattern does not erode as new code is added.
Prerequisites
- Python 3.9+ for
zoneinfo;typing.Protocolfor the interface. pytest >= 8.0.- The injection patterns in dependency injection for testability.
Solution
import time
from datetime import datetime, timedelta, timezone
from typing import Protocol
class Clock(Protocol):
def now(self) -> datetime: ... # aware, UTC
def monotonic(self) -> float: ... # for durations and deadlines
class SystemClock:
def now(self) -> datetime:
return datetime.now(timezone.utc)
def monotonic(self) -> float:
return time.monotonic()
class FakeClock:
"""A clock the test drives explicitly."""
def __init__(self, start: datetime = datetime(2026, 1, 1, tzinfo=timezone.utc)):
self._now = start
self._mono = 1_000.0
def now(self) -> datetime:
return self._now
def monotonic(self) -> float:
return self._mono
def advance(self, **delta) -> None:
step = timedelta(**delta)
self._now += step
self._mono += step.total_seconds() # both clocks move together
class TokenService:
def __init__(self, clock: Clock = SystemClock()) -> None: # default: real time
self._clock = clock
def issue(self, ttl: timedelta) -> "Token":
return Token(expires_at=self._clock.now() + ttl)
def is_valid(self, token: "Token") -> bool:
return self._clock.now() < token.expires_at
def test_token_expires_after_its_ttl():
clock = FakeClock()
service = TokenService(clock)
token = service.issue(ttl=timedelta(hours=1))
assert service.is_valid(token)
clock.advance(hours=1, seconds=1) # explicit, instant
assert not service.is_valid(token)
Why this works
The service no longer knows where time comes from; it asks its clock. In production the default SystemClock returns real time, so no call site changes. In tests the FakeClock returns whatever the test has set, and advance moves it forward instantly by exactly the amount the test specifies. The assertion then states the requirement directly — valid before the TTL, invalid one second after — with no reliance on how long the test takes to run.
Because only the service holds the fake clock, nothing else in the process is affected. Log records carry real timestamps, database drivers see real time for their own timeouts, and TLS certificate validation does not suddenly fail because the process believes it is 2020. That isolation is the main practical advantage over process-wide patching. It also makes the test's intent visible: the reader sees the clock being constructed and advanced, instead of having to know that a decorator three lines up has replaced a standard-library class for the duration of the function.
Edge cases and failure modes
- Naive datetimes. A clock returning naive datetimes invites timezone bugs. Always return aware UTC from
now()and convert to local time at the edges. - Wall time used for durations. Wall-clock time can jump backwards under NTP adjustment. Deadlines and elapsed-time measurements should use
monotonic(). - Default argument evaluated once.
def __init__(self, clock=SystemClock())shares one instance across all services, which is fine for a stateless clock and wrong for anything with state. UseNoneand construct inside if the clock ever gains state. - Code that sleeps. Advancing a fake clock does not wake a thread blocked in
time.sleep. Inject a sleep function too, or have the fake'ssleepadvance time instead of blocking. - Third-party code reading time. Libraries that call
datetime.now()themselves are not affected by the injected clock. That is usually desirable; when it is not,freezegunscoped to one test remains an option.
Testing calendar logic with a controlled clock
Expiry is the simplest case. The more valuable one is calendar logic — billing periods, business days, month-end processing, daylight-saving transitions — where the interesting behaviour happens at specific moments that a test running at an arbitrary real time will almost never hit.
A fake clock lets the test choose those moments deliberately. Start it at 23:59:59 on the last day of a month and advance one second; start it an hour before a daylight-saving transition in the customer's timezone and advance two hours; start it on a Friday evening and advance to Monday morning. Each scenario is a two-line arrangement, and each exercises exactly the boundary where calendar bugs live. Because the start time is an explicit argument, the scenario is also self-documenting: a reader sees immediately that this test is about the last second of March, rather than having to infer it from a frozen-time decorator elsewhere in the file. Scenarios like these belong in every scheduling module's tests.
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
def test_invoice_is_generated_at_month_end_in_the_customer_zone():
tz = ZoneInfo("Europe/London")
clock = FakeClock(start=datetime(2026, 3, 31, 23, 59, 59, tzinfo=tz).astimezone(timezone.utc))
billing = BillingScheduler(clock=clock)
assert billing.due_invoices() == []
clock.advance(seconds=1) # crosses into April, local time
assert [i.period for i in billing.due_invoices()] == ["2026-03"]
The same test written against real time would need to run at precisely that second, in that timezone, to exercise the boundary — which is to say it would never run at all, and the month-end bug would ship. Combined with generated start times from constraining dates and timezones in strategies, a fake clock turns calendar edge cases from rare accidents into routine test cases.
Clocks that sleep as well as tell
Code that waits — retry loops, pollers, rate limiters — needs to sleep as well as read time, and a fake clock that only answers questions leaves those code paths waiting for real. Giving the clock a sleep method, and having the fake implement it by advancing its own time, makes waiting code instant in tests without changing its logic.
class FakeClock:
# … now(), monotonic(), advance() as above …
def sleep(self, seconds: float) -> None:
self.slept.append(seconds) # record what was asked for
self.advance(seconds=seconds) # and pretend it happened
def test_backoff_doubles_between_attempts():
clock = FakeClock()
clock.slept = []
fetch = Mock(side_effect=[TimeoutError, TimeoutError, "ok"])
assert fetch_with_backoff(fetch, clock=clock, base=0.5) == "ok"
assert clock.slept == [0.5, 1.0] # the schedule, asserted exactly
The test runs in microseconds and asserts the precise backoff schedule — something a test using real sleeps could only approximate with a timing tolerance. It is the pattern developed further in testing retry and backoff logic without waiting.
For async code the same idea applies with an async def sleep that advances time and then yields with await asyncio.sleep(0), so other tasks still get a turn at each simulated wait.
The recorded slept list is worth keeping even where no test asserts on it yet. It turns every waiting code path into something observable, and the first time a retry policy is changed by accident — a base delay doubled, a cap removed — a test that asserts the schedule catches it immediately. Without the record, the only symptom would be slower recovery in production, noticed long after the change that caused it.
Frequently Asked Questions
Why not just use freezegun?freezegun patches datetime process-wide, which affects logging timestamps, database defaults, TLS certificate checks and any library that samples the clock. That is sometimes useful and often surprising. An injected clock affects only the code you pass it to, so a test controls exactly the time it means to control.
Does injecting a clock mean changing every function signature?
Only at the boundaries where time is read. Services receive a clock in their constructor, and everything below them uses self.clock. Pure functions that need "now" take it as an argument, which also makes them easier to reason about.
How do I handle time.monotonic for timeouts and durations?
Give the clock a monotonic() method alongside now(). Durations and deadlines use monotonic time, calendar logic uses wall-clock time, and the fake controls both so tests can advance either one.
Related
- Dependency Injection for Testability — the broader pattern the clock is one instance of.
- Freezing Time with freezegun vs monkeypatch — the patching alternatives and their trade-offs.
- Testing Retry and Backoff Logic Without Waiting — the sleeping fake clock in full.
- Constraining Dates and Timezones in Strategies — generating the start times a FakeClock is seeded with.
← Back to Dependency Injection for Testability