Two versions of the same class. Only one of them can be tested.
# Version A
class RateLimiter:
def allow(self, key: str, limit: int, window: float) -> bool:
now = time.time()
hits = redis_client.lrange(key, 0, -1)
...
To test “does the window roll over”, version A needs a Redis and a way to
make time pass. The test either sleeps — turning a 40-microsecond assertion
into a 10-second one — or patches time.time globally, which is a
string-keyed edit to another module’s namespace that breaks the moment
somebody adds from time import time.
# Version B
class RateLimiter:
def __init__(self, clock: Clock, store: RateLimitStore) -> None:
self._clock = clock
self._store = store
Version B’s test passes a FakeClock you can set to any value and a dict.
It runs in microseconds, tests the boundary condition exactly, and needs no
patching and no container.
That is the whole technique. The parameter is the dependency injection.
Narrow Protocols beat wide ABCs
What type should clock be? Not datetime.datetime, not a TimeService
base class. A Protocol with exactly the method the limiter calls:
class Clock(Protocol):
def now(self) -> float: ...
Narrow is the operative word. A Protocol with one method can be satisfied by a three-line fake; a Protocol with nine methods forces every test to implement nine. Interface width is a cost paid by every implementer, and the right width is “what this consumer actually calls”.
Structural typing is what makes it free: FakeClock does not inherit from
anything and does not import Clock. The checker verifies the match. This is
also what lets the Protocol live in the inner layer while implementations
live outside it, with no import pointing back.
💡Why is @runtime_checkable plus isinstance(obj, Clock) not a way to validate that an object implements your interface?
click to reveal
Because it checks presence of names and nothing else. It does not look at
signatures, parameter counts, or types. An object with an attribute called
now that is an integer passes isinstance(obj, Clock) and then explodes
when you call it.
It is also slow — slow enough that the documentation steers you toward plain
hasattr when you need a runtime check at all.
And hasattr narrowing has its own hole worth knowing: after
if hasattr(x, "now"):, mypy types x.now as Any, so
x.now(1, 2, 3, "garbage") type-checks cleanly. Runtime checks are not a
substitute for the static one.
The correct use of @runtime_checkable is narrow: dispatching on a
duck-typed capability at a boundary where you genuinely cannot know the type
statically. It is not interface validation, and treating it as such gives you
a false sense of a contract you do not have.
The honest comparison
ABC. Nominal: the implementer must inherit. That costs an import pointing
the wrong way and forbids adapting a class you do not own. It buys two real
things: shared implementation (template methods, a mixin of defaults) and
__init_subclass__ hooks for registration. If you want those, an ABC is
right. If you only want a contract, a Protocol is right.
A DI container. It buys genuine lifecycle management — singletons,
request scopes, ordered teardown across dozens of components. It costs magic
and, in Python, it usually costs your type checker: wiring by string key or
by decorator means the checker cannot see what is injected, and every
injected attribute becomes Any. Write the container version and watch
reveal_type go blind. For most services, a main() that constructs the
graph explicitly is smaller, faster and fully checked.
Default-argument injection. Tempting and usually wrong:
def process(items: list[str], clock: Clock = SystemClock()) -> None: ...
The default is evaluated once, at function definition time, so every
caller shares one SystemClock created at import. For a stateless clock that
is merely surprising; for anything holding a connection or a buffer it is the
mutable-default bug with extra steps. Ruff flags it as B008. Use
clock: Clock | None = None and construct inside, or better, require the
argument and construct at the composition root.
💡A test does monkeypatch.setattr("app.limiter.time.time", fake). It passes. What is wrong with it that a passing test cannot tell you?
click to reveal
It is coupled to the implementation’s import style, not to its behaviour.
If somebody refactors import time into from time import time, the patch
target no longer exists in that namespace, and — depending on the tool — the
test either errors on a missing attribute or silently patches something the
code no longer reads. Either way the failure has nothing to do with the
rate limiter.
It is also a string. No checker, no editor, and no rename refactor can follow it. The patch survives every rename of the thing it patches, right up until it does not.
The injected version has neither problem: RateLimiter(clock=FakeClock(), ...)
is checked by mypy, followed by rename, and unaffected by how the production
clock happens to be imported. The test tells you about the limiter, which is
what you wanted to know.
The rule
If a class reaches out to get something — a clock, a connection, a random source, the environment, the filesystem — that reach is the thing that makes it untestable. Pass it in instead, typed as the narrowest Protocol the class actually uses, and construct the real one exactly once, at the top.