04 · Mocking with unittest.mock¶
Module 03 tested a real API over the real network. That's the right call for contract tests — and the wrong call for the fifty tests that only need to know "what does my code do when the weather service times out?" You can't make a live service time out on demand, return a 500, or bill a credit card for free. You replace it, and assert on how your code reacts.
unittest.mock ships with Python. No install, no plugin.
1. The code under test¶
# weather.py
import requests
API = "https://api.example.com/weather"
def current_temperature(city):
"""Return the temperature in Celsius, or None if the service is unreachable."""
try:
response = requests.get(API, params={"city": city}, timeout=5)
except requests.RequestException:
return None
if response.status_code != 200:
return None
return response.json()["temp_c"]
Three behaviours to verify: the happy path, a network failure, and a non-200 response. Only one of those is reachable against a live service.
2. patch — swap the real thing for a fake¶
from unittest.mock import patch
import weather
def test_patch_return_value():
with patch("weather.requests.get") as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"temp_c": 21.5, "city": "Pune"}
assert weather.current_temperature("Pune") == 21.5
mock_get.assert_called_once_with(
"https://api.example.com/weather", params={"city": "Pune"}, timeout=5
)
Two assertions, doing different jobs. The first checks the return value — did our parsing work. The second checks the interaction — did we call the service correctly, with the right params and a timeout. Interaction assertions are the entire point of mocking; without them you've only proved your code can read a dict.
Patch where it's used, not where it's defined
patch("weather.requests.get") — correct. patch("requests.get") also
happens to work here, but only because weather.py does import requests
and looks the attribute up at call time. If weather.py had written
from requests import get, then weather.get is a separate name bound at
import time, and patching requests.get would do nothing at all — your test
would silently hit the real network. The rule: patch the name in the module
that calls it.
3. side_effect — raise, or return a sequence¶
return_value covers "the call succeeds". side_effect covers everything else.
import requests
from unittest.mock import patch
def test_side_effect_raises():
with patch("weather.requests.get", side_effect=requests.Timeout("timed out")):
assert weather.current_temperature("Pune") is None
That's a timeout on demand — a scenario you cannot reliably produce against a real service. Pass a list and each call pops the next item, which is how you test retry logic:
from unittest.mock import Mock
def test_side_effect_sequence():
ok = Mock(status_code=200)
ok.json.return_value = {"temp_c": 30.0, "city": "Chennai"}
with patch("weather.requests.get", side_effect=[requests.Timeout(), ok]) as mock_get:
assert weather.current_temperature_with_retry("Chennai") == 30.0
assert mock_get.call_count == 2
First call raises, second succeeds, and call_count == 2 proves the retry
actually happened rather than the first attempt quietly succeeding.
side_effect can also be a function, when the fake response should depend on the
arguments:
def fake_get(url, params=None, timeout=None):
city = params["city"]
if city == "Atlantis":
return Mock(status_code=404)
return Mock(status_code=200, **{"json.return_value": {"temp_c": 25.0}})
4. Real run¶
============================= test session starts ==============================
platform darwin -- Python 3.11.2, pytest-9.1.1, pluggy-1.6.0
collected 5 items
test_mock.py::test_patch_return_value PASSED [ 20%]
test_mock.py::test_side_effect_raises PASSED [ 40%]
test_mock.py::test_side_effect_sequence PASSED [ 60%]
test_mock.py::test_autospec_catches_wrong_signature PASSED [ 80%]
test_mock.py::test_mock_without_spec_accepts_anything PASSED [100%]
============================== 5 passed in 0.08s ===============================
0.08 seconds, zero network traffic, and it covers a timeout path that the live API tests in module 03 can never reach.
5. The decorator and fixture forms¶
Three equivalent styles. Pick one per project and stick to it.
# Context manager — patch is active for exactly these lines
def test_ctx():
with patch("weather.requests.get") as mock_get:
...
# Decorator — note the argument order is bottom-up
@patch("weather.requests.get")
def test_decorator(mock_get):
...
@patch("weather.log_metric") # -> second argument
@patch("weather.requests.get") # -> first argument
def test_two_patches(mock_get, mock_log):
...
# Fixture — reusable across a file
import pytest
@pytest.fixture
def mock_get():
with patch("weather.requests.get") as m:
m.return_value.status_code = 200
m.return_value.json.return_value = {"temp_c": 21.5}
yield m
The stacked-decorator argument order (nearest decorator first) is the single most common source of "why is my mock configured wrong" confusion. The fixture form avoids the question entirely.
6. autospec — mocks that can't lie¶
A bare Mock() accepts anything:
def test_mock_without_spec_accepts_anything():
fake = Mock()
fake.method_that_does_not_exist()
fake.typo_metohd().chained().deeply()
assert True # passes — none of that exists on the real object
That test passes. It is also worthless: it would keep passing after you renamed the real method, deleted it, or changed its signature. This is how a fully green suite ships a broken release.
autospec=True builds the mock from the real object's signature:
def test_autospec_catches_wrong_signature():
with patch("weather.requests.get", autospec=True) as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"temp_c": 5.0}
weather.current_temperature("Oslo")
args, kwargs = mock_get.call_args
assert kwargs["timeout"] == 5
Now calling a method that doesn't exist raises AttributeError, and calling one
with the wrong arguments raises TypeError — at test time, where you want it.
Default to autospec=True. Use Mock() only for throwaway stand-ins where
the shape genuinely doesn't matter.
7. Inspecting what happened¶
| Assertion | Checks |
|---|---|
mock.assert_called() |
Called at least once |
mock.assert_called_once() |
Called exactly once, any args |
mock.assert_called_with(a, b=1) |
The most recent call matched |
mock.assert_called_once_with(...) |
Called exactly once, with these args |
mock.assert_not_called() |
Never called |
mock.assert_any_call(...) |
At least one call matched |
mock.call_count |
How many times |
mock.call_args |
(args, kwargs) of the last call |
mock.call_args_list |
Every call, in order |
The typo that disables your assertion
mock.assert_called_once() is real. mock.assert_called_once (no parens) is
a truthy attribute, and the line does nothing. Worse, Mock invents any
attribute you ask for, so mock.assert_called_onse() silently passes too.
Two defences: autospec=True, and Mock(spec=...), both of which reject
unknown attribute names. Python 3.5+ catches the misspelled-assert_* case
on Mock itself, but not on every mock-like object you'll meet.
8. When not to mock¶
Mocking has a real cost: every mock is an assumption about how the collaborator behaves, and assumptions rot. If the real service changes its response shape, your mocked tests keep passing and production breaks. That's the mocking trade-off, and it's why the pyramid needs both layers.
| Mock it | Don't mock it |
|---|---|
| Third-party HTTP APIs (slow, rate-limited, costs money) | Your own pure functions |
Clocks and randomness (datetime.now, random) |
The standard library's data structures |
| Email/SMS/payment gateways | The thing the test is actually about |
| Failure modes you can't trigger (timeout, 500, disk full) | A fast in-memory database, when a real one is available |
Mocking the system under test
You mocked the exact behaviour you set out to verify. Mock the cart's dependencies (a tax service, a discount API) — never the cart.Over-specified interaction assertions
assert_called_once_with(url, params={...}, timeout=5, headers={...}, verify=True)
fails the day someone adds an unrelated header. Assert on the arguments the
behaviour depends on (kwargs["timeout"], kwargs["params"]["city"]) and
let the rest vary.
How It Actually Works¶
unittest.mock.patch does something very literal: it looks up the target you named
("mymodule.requests.get"), reads the object currently bound to that name via
attribute traversal, saves it, and — for the duration of the with block or
decorated function — overwrites that same attribute slot in mymodule's __dict__
(or the target object's __dict__/__class__.__dict__ for instance/class
attributes) with a MagicMock instance. When your production code later executes
requests.get(...), Python's attribute lookup finds the patched MagicMock in that
__dict__ slot instead of the real function — nothing about requests itself
changed; only the name binding inside mymodule's namespace did. This is exactly why
patch("requests.get") at the wrong location is the single most common mocking
mistake: you must patch the name where it's looked up (the importing module's
namespace), not where it's originally defined, because Python resolves requests.get
inside your module via that module's own imported reference, not a live pointer back
to the requests package.
side_effect works by MagicMock.__call__ checking, on every invocation, whether
side_effect is set: a callable gets invoked with the same arguments and its return
value (or raised exception) is used; an iterable has next() called on it each call,
raising StopIteration once exhausted — this is plain Python iterator protocol, not
special mock magic.
autospec=True introspects the real object's signature via inspect.signature at
patch time and builds a mock that raises TypeError if called with an incompatible
signature or if you access an attribute the real object doesn't have — this is what
catches "the mock still passes after the real API changed" drift that a plain
MagicMock (which accepts and returns a mock for literally any attribute access)
cannot.
Cheat sheet¶
| Need | Code |
|---|---|
| Replace a name | patch("module_that_uses_it.name") |
| Fake a return | mock.return_value = x |
| Fake a chained call | mock.return_value.json.return_value = {...} |
| Raise an exception | side_effect=Timeout() |
| Different result per call | side_effect=[a, b, c] |
| Result depends on args | side_effect=some_function |
| Signature-safe mock | patch(..., autospec=True) |
| Restrict attributes | Mock(spec=RealClass) |
| Patch a dict entry | patch.dict(os.environ, {"ENV": "test"}) |
| Patch an attribute | patch.object(Client, "send") |
| Async collaborator | AsyncMock |
Exercise¶
Using weather.py from section 1:
- Write the three core tests: happy path,
requests.Timeout, and a 500 response. Confirm all three pass with no network access (disable Wi-Fi and re-run — the suite must stay green). - Change
weather.pytofrom requests import getand re-run your tests withpatch("weather.requests.get"). Record what happens and explain why, then fix the patch target. - Write a test using bare
Mock()that asserts a method you invented was called. Watch it pass. Addautospec=Trueand record the exact exception. - Write
current_temperature_with_retryso it retries three times, then useside_effect=[Timeout(), Timeout(), ok_response]to prove the third attempt succeeds andcall_count == 3. - Use
patch("weather.datetime")(orfreezegun, if you prefer) to make a function that stampsdatetime.now()return a fixed timestamp, and explain in a comment why the test was flaky before you did.