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.
unittest.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
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.
# 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.
# 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.
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