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
Hide 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.