Skip to content
← Back to explainers

Python explainer

Pass In Dependencies Instead of Patching Them

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

Pass the clock or the payment service in as a parameter, so a test can swap in a fake. Patch only the names you cannot pass in.

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

A password-reset link should stop working after 30 minutes. Here is a first version:

import time


class ResetLink:
    def __init__(self) -> None:
        self._created = time.monotonic()

    def expired(self) -> bool:
        return time.monotonic() - self._created >= 30 * 60

time.monotonic() returns a number of seconds that only goes up. To test that a link expires, your test would have to wait 30 minutes.

Pass the clock in

Give ResetLink its clock as a parameter. Production code keeps the default, time.monotonic. A test passes a fake clock that it moves forward by hand. Save this as test_links.py and run pytest:

import time
from collections.abc import Callable


class ResetLink:
    def __init__(self, clock: Callable[[], float] = time.monotonic) -> None:
        self._clock = clock
        self._created = clock()

    def expired(self) -> bool:
        return self._clock() - self._created >= 30 * 60


class FakeClock:
    def __init__(self) -> None:
        self.now = 0.0

    def __call__(self) -> float:
        return self.now


def test_link_expires_after_30_minutes() -> None:
    clock = FakeClock()
    link = ResetLink(clock=clock)
    clock.now += 30 * 60 - 1
    assert not link.expired()
    clock.now += 1
    assert link.expired()

Callable[[], float] means a function that takes no arguments and returns a float. time.monotonic is one. A FakeClock object counts as one too, because its __call__ method lets you call it like a function. The test finishes at once and checks both sides of the 30-minute line.

This is dependency injection. The code receives what it depends on, such as a clock, a database or a payment service, instead of looking it up itself. The dependency shows in the signature, and a test can swap it.

Describe larger dependencies with a Protocol

A clock is one function. A payment gateway has methods, so describe it with a Protocol. Any class with the listed methods fits, without inheriting from the Protocol.

from dataclasses import dataclass
from typing import Protocol

import pytest


@dataclass(frozen=True)
class Receipt:
    id: str


class Gateway(Protocol):
    def charge(self, customer: str, cents: int) -> Receipt: ...


def charge_customer(customer: str, cents: int, *, gateway: Gateway) -> Receipt:
    if cents <= 0:
        raise ValueError("charge must be positive")
    return gateway.charge(customer, cents)


class FakeGateway:
    def __init__(self) -> None:
        self.calls: list[tuple[str, int]] = []

    def charge(self, customer: str, cents: int) -> Receipt:
        self.calls.append((customer, cents))
        return Receipt(id="test-receipt")


def test_charge() -> None:
    gateway = FakeGateway()
    assert charge_customer("cus_1", 500, gateway=gateway) == Receipt("test-receipt")
    assert gateway.calls == [("cus_1", 500)]


def test_rejects_nonpositive_charge() -> None:
    gateway = FakeGateway()
    with pytest.raises(ValueError, match="charge must be positive"):
        charge_customer("cus_1", 0, gateway=gateway)
    assert gateway.calls == []

FakeGateway records every charge, so a test can check what the code sent. The second test checks that charge_customer rejects a bad amount without calling the gateway. mypy checks that FakeGateway has the method that Gateway lists. In the application, build the real gateway once, where the program starts, and pass it to the code that needs it.

A fake can still behave differently from the real service. Keep a few integration tests against the real service, or its test mode, for details such as authentication and error responses.

Patch the name your code looks up

Sometimes you cannot change the code you test. Patching replaces an attribute, such as a function in a module, for the length of one test. pytest's monkeypatch fixture does this, and it restores the original when the test ends, even if the test fails. With it, you can test the first ResetLink unchanged:

import time

import pytest


class ResetLink:
    def __init__(self) -> None:
        self._created = time.monotonic()

    def expired(self) -> bool:
        return time.monotonic() - self._created >= 30 * 60


def test_link_expires(monkeypatch: pytest.MonkeyPatch) -> None:
    now = 0.0
    monkeypatch.setattr(time, "monotonic", lambda: now)
    link = ResetLink()
    now = 30 * 60
    assert link.expired()

The lambda reads now each time it runs, so changing now moves the clock.

This works because ResetLink looks up monotonic on the time module each time it reads the clock. Patch the name that your code looks up. If links.py says from time import monotonic, it has its own name monotonic, which still points at the real function. Patch it with monkeypatch.setattr(links, "monotonic", fake).

Check your understanding Your module links.py has from time import monotonic. A test patches time.monotonic, but links still reads the real clock. Why? Show answer

The import copied a reference to the real function into links, under the name monotonic. Patching time.monotonic changes the time module only, so links.monotonic still points at the real function. Patch links.monotonic instead. Better still, pass the clock in, and the test no longer depends on how links imports it.

Give a mock the real function's signature

A mock is a stand-in object from unittest.mock that accepts calls and records them. A plain Mock accepts any method name and any arguments, so a typo can pass a test. create_autospec builds a mock that only accepts calls the real function would accept:

from unittest.mock import create_autospec

import pytest


def send(customer: str, *, cents: int) -> str:
    return "sent"


def test_specified_mock() -> None:
    sender = create_autospec(send, return_value="queued")
    assert sender("cus_1", cents=500) == "queued"
    sender.assert_called_once_with("cus_1", cents=500)

    with pytest.raises(TypeError):
        sender("cus_1", amount=500)

Autospec checks the names and number of arguments, not their types. mypy sees a mock and its methods as Any, so it checks nothing about them. A small fake class, like FakeGateway, gets full type checking.

Pass dependencies into code you own, and patch the rest

Pass dependencies in when you write the code. Patch when you test code you cannot change, or something global such as an environment variable. Pass in what is slow, random or outside your program, such as clocks, networks and payment services. Leave the rest as plain code, because a simple function with too many parameters is hard to read.

The next exercise, Pass In a Fake Clock, has you build a session that expires and reads the time only through a clock you pass in.

Going further

monkeypatch.context() undoes its patches at the end of a with block instead of at the end of the test. A patch changes the module for every thread while it is active. See pytest's monkeypatch guide and "Where to patch" in the unittest.mock documentation.

step 5 of 19

Testing with pytest and Hypothesis

Up next Pass In a Fake Clock