Skip to content
← All Python articles

Python explainer

Pass Dependencies In Instead of Importing Them

About 3 min to read. Allow extra time to work through examples.

Pass a clock or a store into the code that uses it, so tests can control time without sleeping or patching.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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

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.

step 15 of 41

Modules, Packaging and Tooling

Up next Pass a Clock and a Store into a Rate Limiter