A function decides whether a coupon has expired:
import time
def expired(expires_at: float) -> bool:
return time.time() >= expires_at
To test the moment of expiry, you must either sleep until it passes or patch
time.time. Sleeping makes the test slow, and the patch breaks if someone
changes the import to from time import time.
Pass the clock in instead. Dependency injection means passing a collaborator, such as a clock or a database client, in as an argument. The code that uses it no longer creates or imports it. A constructor parameter is enough. You do not need a framework.
Inject a clock
from typing import Protocol
class Clock(Protocol):
def now(self) -> float: ...
class FakeClock:
def __init__(self, value: float = 0.0) -> None:
self.value = value
def now(self) -> float:
return self.value
class Deadline:
def __init__(self, clock: Clock, delay: float) -> None:
self._clock = clock
self._expires_at = clock.now() + delay
def expired(self) -> bool:
return self._clock.now() >= self._expires_at
clock = FakeClock()
deadline = Deadline(clock, delay=10.0)
clock.value = 9.0
assert not deadline.expired()
clock.value = 10.0
assert deadline.expired()
FakeClock does not inherit from Clock. It has a matching now method, so
it fits the Protocol. mypy checks the match where you pass it to Deadline.
The test sets the time to 9 and then to 10, and it runs instantly.
In production, wrap time.monotonic():
import time
class SystemClock:
def now(self) -> float:
return time.monotonic()
time.monotonic() never goes backwards, so it suits measuring how much time
has passed. Its starting point is arbitrary, so compare its values only
within one process. If code needs only the one call, it can take
a plain callable, now: Callable[[], float], instead of a Protocol.
Inject a store
The exercise also injects a RateLimitStore with get and set methods. A
test passes an in-memory store. A plain dict lacks a set method, so the
exercise wraps a dictionary in a small MemoryStore
class.
The Protocol fixes the method names and types, not the behavior. Decide what
get returns for a missing key, which is None in the exercise, and whether
callers may change the list it returns. Write those rules down. A fake store
and a real one can both fit the Protocol and still disagree on them.
Pass the real clock and store in at startup, usually in main. The
next article is about that startup code.
Common mistakes
Creating a default in the signature. With
def __init__(self, clock: Clock = SystemClock()), Python creates one
SystemClock when it defines the function. Every call that omits the
argument shares it. That is harmless for a stateless clock. For an object
that holds state or needs closing, it is a bug. Make the argument required,
or default it to None and create the object inside.
Calling a separate get and set from several threads. The exercise
handles one request at a time. With threads, two requests can both read the
same list before either writes it back, and both get through. A real limiter
needs a lock, or a store with one operation that checks and updates together.
Injecting the store does not change that.
Check your understanding
When is unittest.mock.patch still the right tool?
Show answer
Hide answer
When you cannot change the code to take the dependency, for example in old
code that you test before a refactor. Patch the name where the code looks it
up. If coupons.py does from time import time, patch coupons.time, not
time.time. Injection avoids that trap.
Check your understanding
Does @runtime_checkable let isinstance check that an object fits a Protocol?
Show answer
Hide answer
Only partly. isinstance then checks that each method exists by name. It
does not check the arguments or the return types. Use mypy to check
signatures, and test the behavior.
See where to patch
and typing.runtime_checkable
in the Python documentation.