Skip to content

Chapter 10 · Mock Testing and monkeypatch ​

Chapter goals: master the full monkeypatch API built into pytest, combine it with unittest.mock (and pytest-mock's mocker), 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):

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

python
# 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 branch

10.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:

python
# content of app.py
import requests


def get_json(url):
    """Fetch url and parse JSON."""
    r = requests.get(url)
    return r.json()
python
# 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:

python
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:

python
# 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 made

The @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:

python
# 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 arguments

How 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:

  1. Mocking everything into an empty shell: even the logic under test is mocked; tests stay forever green yet convey nothing;
  2. Mocking private implementation details: patch("service._internal_helper") makes any refactor explode tests — violating Chapter 1's "test behavior, not implementation".
python
# ❌ 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 executes

Rule of thumb: mock only at architectural boundaries (HTTP, databases, message queues, clocks, randomness); let domain logic run for real.

10.5 Chapter Summary ​

  • monkeypatch covers setattr/delattr/setitem/setenv/chdir/syspath/context; default raising=True catches typos;
  • Patch targets must be names referenced by the consumer: import x vs from x import f have different correct patch points;
  • Use unittest.mock or pytest-mock's mocker when 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 ​

  1. Test a config module reading os.environ["DB_URL"]: cover "present / missing / empty" (hints: setenv + delenv).
  2. Use mocker to test a function calling a weather HTTP API, asserting both parsed results and that the URL/parameters were correct.
  3. Deliberately apply patch("app.requests.get") to a from 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.