Skip to content

← Tests That Earn Their Keep step 6 of 19

Medium Primitives

A rate limiter with an injected clock

A token-bucket rate limiter that calls time.monotonic() directly is untestable, and the industry’s usual answer — mock.patch("mymodule.time") — is worse than the disease.

Patching binds your test to the import spelling in the module under test. Change import time to from time import monotonic and the patch silently stops applying; the test still passes, because a rate limiter that never refills still denies things. pytest’s own documentation puts the alternative plainly:

For code that you control, a safer long-term pattern is to make dependencies explicit so they can be passed into the code under test instead of patched globally.

So: take the clock as an argument.

What to write

def make_rate_limiter(
    *, capacity: int, refill_per_second: float, clock: Clock
) -> Callable[[int], bool]

It returns allow(n) — True if n tokens were available and consumed, False otherwise. The bucket starts full, at capacity tokens, and refills at refill_per_second continuously, never above capacity.

The only “now” it may consult is clock.monotonic(). Do not import time, datetime or random. The tests drive a FakeClock whose readings start at 0.0; a real clock returns a number in the millions and every bucket you build would look like it had been idle since the machine booted, so a real clock does not merely make the test slow, it makes it wrong.

The hostile clock

monotonic promises never to go backwards. Across a suspend, a container migration, or a VM snapshot restore, it does anyway — and so does any clock a colleague hands you in six months. Your limiter must survive a decreasing reading:

  • tokens must never exceed capacity,
  • tokens must never go below zero,
  • and it must not raise.

The naive elapsed = now - last computes a negative refill, drives the bucket deeply negative, and then denies every request for a long time afterwards while looking perfectly correct in every test written with an increasing clock. Treat time going backwards as no time passing.

Each limiter owns its own bucket. Two limiters built from the same clock share nothing — a module-level counter fails that case immediately.

Why a Protocol and not a Mock

Clock is a Protocol with one method. FakeClock does not inherit from it and does not register with it; it satisfies it structurally, and mypy checks the fake against the same port as the real implementation. That is the entire argument against MagicMock, whose type is Any: a mock accepts clock.monotonik(), clock.monotonic(1, 2, "garbage") and every other call you could make, and reports nothing. A fake that type-checks cannot drift out of sync with the interface, because the interface is a type.

Note also what you must not do: isinstance(clock, Clock) raises TypeError on a Protocol that is not @runtime_checkable, and even with the decorator it would only check that the attribute exists, not its signature.