Why does calling time.monotonic() inline make a function hard to test deterministically?
answer
- The function reaches for a global
- Time is a dependency, not a fact
- Pass the clock in as a parameter
- Default it to time.monotonic itself
- Fake returns scripted ticks, nothing sleeps
basics
~20 sBecause the function reaches into a process-wide clock the test cannot control, so its timing branches only fire after real time passes. Accept a clock callable parameter defaulting to time.monotonic and pass a fake in the test.
solid answer
~40 sThe clock is a dependency that never appears in the signature, so the only lever a test has is real elapsed time: to reach the timeout branch it must actually wait, which is slow and goes intermittent on a loaded machine. Make the dependency explicit — `def wait_for(check, timeout, clock=time.monotonic)` — and note that the default is the function object, not `time.monotonic()`, which would freeze one timestamp at `def` time. Production callers are unchanged; the test passes a callable over a scripted list of ticks and drives the timeout instantly. Use `time.monotonic` rather than `time.time` for durations, since the wall clock can jump backwards. Patching the imported name also works, but injection is visible in the signature and independent of how the module imported anything.
code
python · 15 linesimport time
def wait_for(check, timeout, clock=time.monotonic):
deadline = clock() + timeout
while clock() < deadline:
if check():
return True
return False
ticks = iter([0.0, 1.0, 2.0])
fake_clock = lambda: next(ticks)
print(wait_for(lambda: False, 2.0, clock=fake_clock))
print(wait_for(lambda: True, 2.0))go deeper
Be ready to say that the function is asking the operating system for the time itself, so the test has no way to choose what it sees. Know that the fix is to hand the time in from outside rather than to sleep longer.
Explain the seam mechanically: a clock parameter defaulting to time.monotonic, why the default must be the function object rather than a call, and how a scripted fake reaches the timeout branch with no elapsed time.
Show the production judgment: monotonic for durations versus wall clock for stamps, faking sleep as well as the clock so backoff schedules are asserted rather than waited out, and keeping one slower realistic test for the behaviour a fake cannot prove.
Own the policy question — where the clock seam lives across a codebase, whether it is a parameter, a single injected clock object or one project-level helper, and what it costs a team when every module reaches for the clock independently.
### Time is a dependency wearing a disguise A function whose body calls `time.monotonic()` has a collaborator that never appears in its signature. Nothing in `def wait_for(check, timeout)` tells a reader — or a test — that the function reaches into a process-wide clock the caller does not own. The practical consequence is that the only lever a test has over the timing branches is *real elapsed time*. To exercise the timeout path the test has to actually wait out the timeout, which makes the suite slow; and because a loaded machine can cross a 50 ms deadline between two statements the test assumed were adjacent, it also makes the suite intermittent. Padding the sleep until it goes green is the classic wrong fix: it buys a slower suite and the same failure at a worse moment. ### The seam: accept the clock as a parameter The cheapest fix is to make the dependency explicit and give it the real implementation as its default: ```python import time def wait_for(check, timeout, clock=time.monotonic): deadline = clock() + timeout while clock() < deadline: if check(): return True return False ``` Production callers pass nothing and behave exactly as before. A test passes a callable of its own and drives the timeline directly. Note the detail that catches people: the default is `time.monotonic`, the *function object*, not `time.monotonic()`, which would evaluate once when the `def` executes and freeze a single timestamp into the signature for the life of the process. ### Writing the fake A fake clock is any zero-argument callable returning a float. A scripted iterator is usually enough — `ticks = iter([0.0, 0.5, 1.5]); clock = lambda: next(ticks)` — and a tiny class with an `advance()` method is better when several assertions share one timeline. Either way the test runs in microseconds, nothing sleeps, and the timeout branch is reached on demand rather than eventually. Because the fake is an ordinary object you can also count its calls, which matters when the code under test is expected to read the clock once per iteration rather than three times. ### Which clock, and why it matters even to the fake `time.monotonic()` never goes backwards and is unaffected by system clock adjustments, so it is the right source for durations, deadlines and backoff. `time.time()` is the wall clock and can jump — an NTP step, a manual correction — so a deadline computed from it may fire instantly or never. `time.perf_counter()` is also monotonic with the highest available resolution and suits benchmarks; `time.monotonic_ns()` returns an integer nanosecond count, which avoids float rounding in a very long-lived process. The zero point of the monotonic clock is undefined, so only *differences* between readings are meaningful — which is precisely why a fake is free to return any floats it likes. ### Injection versus patching Patching the clock with `unittest.mock.patch` also works, and is sometimes the only option when the call sits deep in code you do not own. But it replaces a global for the duration of the test, fixes only the one module you name, and ties the test to how that module happened to import the name. An injected parameter is visible in the signature, expressible as a type hint (`Callable[[], float]`), composes with `functools.partial`, and works the same no matter how the module is imported. A reasonable rule: if the timing logic *is* the thing under test, inject it; if the clock is incidental to what you are asserting, patching is acceptable. ### Sleep is a dependency too The same argument applies to `time.sleep`. A retry loop that sleeps inline cannot be tested without paying the delays; a loop that accepts a `sleep` callable can be handed a recorder, and the test asserts on the requested schedule — `[0.1, 0.2, 0.4]` — instead of waiting 700 ms to observe it. Faking sleep and faking the clock together lets a test assert both the backoff shape and the total budget in no time at all. ### What a fake clock does not buy you A faked clock proves the logic is correct *given a timeline*. It cannot prove behaviour under real scheduler jitter, real GC pauses or a real slow dependency, and it will happily hide a mistaken assumption about how many times the production code reads the clock. Keep at least one slower, realistic test for the integration behaviour, and assert on clock call counts where the number is part of the contract.
- Why prefer time.monotonic over time.time when measuring a timeout?`time.time()` is the wall clock and can jump: an NTP correction or a manual change moves it forwards or backwards, so a deadline computed from it can fire immediately or effectively never. `time.monotonic()` only ever moves forward and ignores clock adjustments, which is what a duration needs. Its zero point is undefined, so only differences between readings mean anything — which is also why a fake may return any floats it likes.
- How would you write the fake so the test controls the timeline precisely?Any zero-argument callable returning a float will do. A scripted iterator covers most cases — `ticks = iter([0.0, 0.5, 1.5]); clock = lambda: next(ticks)` — and a small class with an `advance(seconds)` method is clearer when several assertions share one timeline. Because the fake is an ordinary object you can also count its calls, which matters when reading the clock once per iteration is part of the contract.
- Isn't patching the clock simpler than threading a parameter through?It is fewer keystrokes and sometimes the only option when the call sits deep in code you do not own. But it replaces a global for the duration, fixes only the module you name, and couples the test to how that module imported the name. An injected parameter is visible, type-hintable as `Callable[[], float]`, and composes with `functools.partial`. Inject when the timing logic is the thing under test.
A function that reads the clock itself is a kitchen timer welded to the wall: you cannot rehearse the recipe without sitting through the real minutes.
saying these in an interview costs you the question
- Adds time.sleep to the test until it passes
- Calls timing tests inherently flaky and skips them
- Uses time.time for a deadline, ignoring clock jumps
- Writes clock=time.monotonic(), freezing one timestamp at def time
- Thinks a fake clock also proves behaviour under real scheduler jitter