Mocking the Network and the Clock

Replace the network and the clock in tests: patch the name where it is looked up, and inject the clock rather than freezing it.

Syntaxunittest.mock.patch(target, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs)

A test must give the same answer on a plane as it does in CI. Anything that reaches outside the process — an HTTP call, the system clock, a queue, the filesystem — has to be replaced or injected, or the test is a monitoring check with a flaky schedule. A real API call in a test is worse than slow: it burns rate limit and money, breaks when the vendor is down, and can write to someone's production account.

Two tools

pytest's monkeypatch fixture sets and restores things for the duration of one test: setattr, delattr, setitem, delitem, setenv, delenv, syspath_prepend and chdir. The standard library's unittest.mock.patch(target, new=DEFAULT, spec=None, create=False, spec_set=None, autospec=None, new_callable=None, **kwargs) additionally records calls. @patch("myapp.client.get") as a decorator and with patch("myapp.client.get") as mock_get: are the same object; the decorator form passes the mock in as an argument, and stacked decorators supply them bottom-up.

Patch where the name is looked up

If myapp/client.py does from httpx import get, the name get lives in myapp.client, and patching httpx.get changes nothing the function will read. Patch myapp.client.get. If it does import httpx and calls httpx.get(...), the lookup happens in httpx, so patch httpx.get. The import style decides the target string.

Inject the clock

Rather than patching datetime out from under your code, take the time as an argument — now: datetime, or clock: Callable[[], datetime] defaulting to datetime.now. The test then passes a fixed value, and nothing has to guess which module read the clock.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Test the timeout and 500-response branches of an HTTP client without a server
  • Assert that your code called the vendor with the exact URL, headers and payload you intended
  • Test expiry, retry backoff and end-of-month logic at a fixed instant
  • Keep a suite offline so it runs on a laptop with no credentials and no network

More examples

The real call, and the test that replaces it

The function takes the client as an argument, so the test needs no patching at all - dependency injection removes the problem that mock.patch solves. assert_called_once_with is the part people skip: without it a test passes while the code calls the wrong URL with the wrong timeout.

Example · python
# myapp/prices.py
import httpx


def latest_price(symbol: str, client: httpx.Client) -> float:
    response = client.get(f"https://api.example.com/price/{symbol}", timeout=5.0)
    response.raise_for_status()
    return response.json()["price"]


# tests/test_prices.py
from unittest.mock import MagicMock

import httpx

from myapp.prices import latest_price


def test_latest_price_reads_the_price_field():
    # spec ties the fake to the real class: a method httpx.Client does not
    # have raises AttributeError instead of returning a new mock.
    url = "https://api.example.com/price/ACME"
    client = MagicMock(spec=httpx.Client)
    # A hand-built Response needs its request set: raise_for_status() checks
    # for it before it checks the status, so a bare Response(200, ...) raises
    # RuntimeError instead of returning.
    client.get.return_value = httpx.Response(
        200, json={"price": 101.5}, request=httpx.Request("GET", url)
    )

    assert latest_price("ACME", client) == 101.5
    client.get.assert_called_once_with(url, timeout=5.0)

monkeypatch when the call is buried

Use this when the call is inside a function whose signature you cannot change. The patch target is the importing module, because `from httpx import post` bound a new name there; patching httpx.post would leave this test calling the real webhook.

Example · python
# myapp/notify.py
from httpx import post   # the name now lives in myapp.notify


def notify(event):
    post("https://hooks.example.com/billing", json=event).raise_for_status()


# tests/test_notify.py
class FakeResponse:
    status_code = 200

    def raise_for_status(self):
        return None


def test_notify_posts_the_event(monkeypatch):
    sent = []

    def fake_post(url, json=None, **kwargs):
        sent.append((url, json))
        return FakeResponse()

    # myapp.notify.post, NOT httpx.post - patch where the name is read.
    monkeypatch.setattr("myapp.notify.post", fake_post)
    notify({"id": 7})
    assert sent == [("https://hooks.example.com/billing", {"id": 7})]

A clock you pass in beats a clock you freeze

The default keeps production code reading datetime.now() with no ceremony, and the test chooses the instant with no patching. Compare this to freezing time globally: that has to intercept every way any library might read the clock, and it hides which function actually cared.

Example · python
from collections.abc import Callable
from datetime import datetime, timedelta


def is_expired(
    issued_at: datetime,
    ttl: timedelta,
    now: Callable[[], datetime] = datetime.now,
) -> bool:
    return now() - issued_at > ttl


# tests/test_tokens.py
def test_token_expires_one_second_past_the_ttl():
    issued = datetime(2026, 1, 1, 12, 0, 0)
    clock = lambda: datetime(2026, 1, 1, 12, 0, 31)   # 31s later, fixed
    assert is_expired(issued, timedelta(seconds=30), now=clock) is True
    assert is_expired(issued, timedelta(seconds=60), now=clock) is False

Discussion

  • Be the first to comment on this lesson.