Chapter 10 · Mock Testing and monkeypatch
Chapter goals: master the full
monkeypatchAPI built into pytest, combine it withunittest.mock(andpytest-mock'smocker), and build the judgment for when to mock — and when not to.
10.1 The monkeypatch Family
Previewed in Chapter 9: monkeypatch safely replaces attributes, dict entries, and environment variables, restoring everything automatically after the test. Its complete method list (from the official docs):
| Method | Purpose |
|---|---|
setattr(obj, name, value, raising=True) | Replace attributes/methods |
delattr(obj, name, raising=True) | Delete attributes/methods |
setitem(mapping, name, value) / delitem(...) | Modify dict entries (e.g. global config) |
setenv(name, value) / delenv(name) | Environment variables |
syspath_prepend(path) | Modify sys.path (triggers import cache refresh) |
chdir(path) | Switch current working directory |
context() | Returns a context manager scoping patches to a with block |
The raising=True default on every method means: missing targets raise immediately — protecting you from patching a misspelled attribute and never noticing.
# content of test_env.py
import os
from pathlib import Path
def get_ssh_dir():
return Path.home() / ".ssh"
def test_getssh(monkeypatch):
# replace Path.home with a fixed result, removing dependence on the running user
monkeypatch.setattr(Path, "home", lambda: Path("/abc"))
assert get_ssh_dir() == Path("/abc/.ssh")
# Path.home is restored automatically after the test
def test_missing_config(monkeypatch):
os.environ["API_TOKEN"] = "real-token"
monkeypatch.delenv("API_TOKEN") # simulate "env var missing"
assert load_token_or_none() is None # code under test should take the fallback branch10.2 Replacing Returned Objects via setattr: Mocking Classes
When the call chain is requests.get(url) -> response.json(), simply replace requests.get to return a fake response. The official example defines a class for the fake:
# content of app.py
import requests
def get_json(url):
"""Fetch url and parse JSON."""
r = requests.get(url)
return r.json()# content of test_app.py
import app
import requests
class MockResponse:
@staticmethod
def json():
return {"mock_key": "mock-response"}
def test_get_json(monkeypatch):
monkeypatch.setattr(requests, "get", lambda url: MockResponse())
result = app.get_json("https://fakeurl.com/response/json")
assert result["mock_key"] == "mock-response"What You Patch Is Crucial
Patch the name in the consumer's namespace. Patching requests.get works above because app.py reads import requests; requests.get(url); had it used from requests import get, you'd need to patch app.get — the most classic mocking pitfall.
monkeypatch.context() confines a group of patches to a local scope, ideal for switching environments within one test:
def test_two_modes(monkeypatch):
with monkeypatch.context() as m:
m.setenv("APP_MODE", "debug")
assert read_mode() == "debug"
with monkeypatch.context() as m:
m.setenv("APP_MODE", "prod")
assert read_mode() == "prod"10.3 unittest.mock and pytest-mock
Beyond monkeypatch, the standard library's unittest.mock offers richer doubles. MagicMock records every call and supports call assertions:
# content of test_mock.py
from unittest.mock import MagicMock
import app
def test_get_json_with_magicmock(monkeypatch):
fake_response = MagicMock()
fake_response.json.return_value = {"k": "v"} # configure the return value
monkeypatch.setattr(requests := __import__("requests"), "get",
MagicMock(return_value=fake_response))
assert app.get_json("http://x") == {"k": "v"}
# you can also assert how calls were madeThe @patch decorator is another style, but decorators inject mocks as parameters whose ordering gets error-prone when mixed with pytest fixtures. The community favorite is pytest-mock's mocker fixture — a thin wrapper over mock.patch that auto-undoes, matching monkeypatch's ergonomics:
# pip install pytest-mock
from unittest import mock
def test_fetch(mocker):
mocked = mocker.patch("app.requests.get") # note: the consumer's path
mocked.return_value.json.return_value = {"ok": True}
from app import get_json
assert get_json("http://x") == {"ok": True}
mocked.assert_called_once_with("http://x") # assert call argumentsHow to choose among the three? Simple rules:
- Changing env vars/dicts/cwd →
monkeypatch; - Asserting how something was called (call args, counts) →
unittest.mock/mocker; - Doubles for large objects, interface verification via spec →
MagicMock(spec=...).
10.4 The Over-Mocking Anti-Pattern
Mocking is a scalpel, not a hammer. Two classic anti-patterns:
- Mocking everything into an empty shell: even the logic under test is mocked; tests stay forever green yet convey nothing;
- Mocking private implementation details:
patch("service._internal_helper")makes any refactor explode tests — violating Chapter 1's "test behavior, not implementation".
# ❌ bad: mocking the function under test verifies nothing
def test_bad(mocker):
mocker.patch("app.process_order") # this IS the function under test!
from app import process_order
process_order(1)
process_order.assert_called_once()
# ✅ good: mock only external boundaries (network), run real business logic
def test_good(mocker):
fake_resp = mock.MagicMock()
fake_resp.status_code = 200
mocker.patch("app.requests.get", return_value=fake_resp)
assert handle_webhook("order-1") == "ok" # real domain code executesRule of thumb: mock only at architectural boundaries (HTTP, databases, message queues, clocks, randomness); let domain logic run for real.
10.5 Chapter Summary
monkeypatchcovers setattr/delattr/setitem/setenv/chdir/syspath/context; defaultraising=Truecatches typos;- Patch targets must be names referenced by the consumer:
import xvsfrom x import fhave different correct patch points; - Use
unittest.mockorpytest-mock'smockerwhen asserting call behavior; - Mock architectural boundaries only — never the logic under test or its private internals.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. app.py contains: from requests import get, then calls get(url). To replace it in tests, the correct patch target is:
2. What does raising=True do in monkeypatch.setattr(obj, "name", value, raising=True)?
3. Which requirement best fits unittest.mock rather than plain monkeypatch.setattr?
4. Which practice is the "over-mocking" anti-pattern?
🛠️ Hands-On Exercises
- Test a config module reading
os.environ["DB_URL"]: cover "present / missing / empty" (hints:setenv+delenv). - Use
mockerto test a function calling a weather HTTP API, asserting both parsed results and that the URL/parameters were correct. - Deliberately apply
patch("app.requests.get")to afrom requests import get-style module to reproduce the "patch does nothing" bug, fix it with the correct target, and record both outputs.
Next chapter examines pytest's configuration system — the four config files' precedence, rootdir determination, and the most-used ini options: Chapter 11.