Isolation & Contracts

Injecting a Clock Instead of Patching datetime

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

Solution

Python
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
Python
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)
Patched datetime versus an injected clock Two approaches. Patching datetime replaces the clock for the whole process, so logging, database drivers and third-party libraries all see the fake time. An injected clock is passed only to the service under test, so only that service sees controlled time and everything else keeps the real clock. Who sees the fake time patch datetime / freezegun TokenService logging DB driver TLS checks everything in the process is frozen injected FakeClock TokenService logging DB driver TLS checks only the code under test is controlled
The gold boxes see fake time. On the left that includes infrastructure the test never meant to touch; on the right it is exactly the service under test.

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. Use None and 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's sleep advance 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, freezegun scoped 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.

Python
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.

Placing the clock at the boundaries that matter Four calendar boundaries a fake clock can start just before: the last second of a month, the hour before a daylight-saving change, Friday evening before a weekend, and the last second of a year. Advancing across each boundary exercises logic that real-time tests would almost never reach. Start just before the boundary, then cross it month end 31 Mar 23:59:59 → +1 s billing periods, statements daylight-saving change hour before → +2 h schedules, duplicated or skipped hours weekend Friday 18:00 → Monday 09:00 business-day calculations year end 31 Dec 23:59:59 → +1 s annual limits, ISO week numbers
Each boundary is a two-line arrangement with a fake clock and practically unreachable with a real one.

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.

Python
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.

A fake clock that sleeps by advancing itself The code under test calls clock.sleep with half a second, then one second, between attempts. The fake records each requested duration and advances its own time by that amount instantly. The test asserts the recorded schedule exactly, in microseconds, where real sleeps would take one and a half seconds and need a tolerance. Waiting code, instant tests, exact assertions attempt 1 fails sleep(0.5) recorded, advanced attempt 2 fails sleep(1.0) recorded, advanced assert clock.slept == [0.5, 1.0] exact schedule · microseconds of wall time · no tolerance needed
Real sleeps would cost a second and a half per run and could only be checked approximately. The recorded schedule is checked exactly.

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.

← Back to Dependency Injection for Testability