Skip to main content
Pytest intermediate Lesson 6 of 10

Monkeypatching and Mocking in Pytest

Replace network calls, clocks and environment variables in a test using monkeypatch and unittest.mock, and learn why patching the wrong name silently does nothing.

A test that calls a live API is slow, flaky, and fails on a train. monkeypatch replaces the thing it calls, and puts the original back when the test ends.

The code under test

# weather.py
import os
import requests

BASE = "https://api.example.com/weather"


def current_temperature(city: str) -> float:
    resp = requests.get(BASE, params={"city": city}, timeout=5)
    resp.raise_for_status()
    return resp.json()["temp_c"]


def api_key() -> str:
    key = os.environ.get("WEATHER_API_KEY")
    if not key:
        raise RuntimeError("WEATHER_API_KEY is not set")
    return key
# test_weather.py
from weather import current_temperature


def test_reads_the_temperature():
    assert current_temperature("Leeds") == 11.4
$ pytest -q
E   requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com',
E   port=443): Max retries exceeded with url: /weather?city=Leeds (Caused by
E   NameResolutionError("Failed to resolve 'api.example.com'"))
1 failed in 5.21s

Five seconds to fail, and it would have failed differently tomorrow when the real temperature was 12.1. The network is not what this test is about.

Replacing the call

# test_weather.py
import weather


class FakeResponse:
    def __init__(self, payload, status=200):
        self._payload = payload
        self.status_code = status

    def raise_for_status(self):
        if self.status_code >= 400:
            raise requests.HTTPError(f"{self.status_code} Server Error")

    def json(self):
        return self._payload


def test_reads_the_temperature(monkeypatch):
    def fake_get(url, params=None, timeout=None):
        assert params == {"city": "Leeds"}
        return FakeResponse({"temp_c": 11.4})

    monkeypatch.setattr(weather.requests, "get", fake_get)

    assert weather.current_temperature("Leeds") == 11.4
$ pytest -q
.                                                                 [100%]
1 passed in 0.01s

Five seconds became ten milliseconds. monkeypatch recorded the original requests.get and restores it after the test — including when the test fails, which is the difference between it and a bare setattr.

The error path is now trivial to reach:

def test_server_error_propagates(monkeypatch):
    monkeypatch.setattr(weather.requests, "get",
                        lambda *a, **kw: FakeResponse({}, status=503))

    with pytest.raises(requests.HTTPError, match="503"):
        weather.current_temperature("Leeds")
$ pytest -q
..                                                                [100%]
2 passed in 0.02s

Getting a real API to return 503 on demand is a project. Getting a fake to is one line — and error handling is where the bugs actually are.

Patch where it is looked up

Change the import in weather.py to bind the function directly:

# weather.py
from requests import get

def current_temperature(city: str) -> float:
    resp = get(BASE, params={"city": city}, timeout=5)
    ...

The same test now hits the network again:

$ pytest -q
E   requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com', port=443)
1 failed in 5.19s

from requests import get copied the function object into weather’s namespace at import time. Patching requests.get rebinds the attribute on the requests module, and weather.get still points at the original. Patch the name the code actually calls:

    monkeypatch.setattr(weather, "get", fake_get)
$ pytest -q
.                                                                 [100%]
1 passed in 0.01s

Patch where it is used, not where it is defined. This one rule accounts for most patches that appear to do nothing. monkeypatch.setattr also takes a dotted string, which reads well when the target is nested:

    monkeypatch.setattr("weather.requests.get", fake_get)

A typo in that string is caught rather than silently creating an attribute:

E       AttributeError: <module 'weather'> has no attribute 'requsts'

Environment, dicts, and the working directory

def test_missing_key_is_rejected(monkeypatch):
    monkeypatch.delenv("WEATHER_API_KEY", raising=False)

    with pytest.raises(RuntimeError, match="WEATHER_API_KEY"):
        weather.api_key()


def test_key_is_read_from_env(monkeypatch):
    monkeypatch.setenv("WEATHER_API_KEY", "test-key-123")

    assert weather.api_key() == "test-key-123"
$ pytest -q
..                                                                [100%]
2 passed in 0.01s

raising=False means “do not fail if it was not set”, which is what you want when the variable is present on your laptop and absent in CI. Both tests pass in either environment because neither depends on what was there before.

The same fixture covers dictionaries and the current directory:

def test_uses_the_staging_endpoint(monkeypatch):
    monkeypatch.setitem(weather.CONFIG, "base_url", "https://staging.example.com")
    assert weather.endpoint() == "https://staging.example.com/weather"


def test_reads_config_from_cwd(monkeypatch, tmp_path):
    (tmp_path / "weather.toml").write_text('units = "F"\n')
    monkeypatch.chdir(tmp_path)

    assert weather.load_config()["units"] == "F"
$ pytest -q
..                                                                [100%]
2 passed in 0.02s

Every one of these is undone at the end of the test. A suite that mutates global state and tidies up by hand starts failing the moment someone reorders it.

Freezing a clock

Time is the other global that ruins tests:

# billing.py
from datetime import datetime, timezone


def invoice_reference(customer_id: int) -> str:
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
    return f"INV-{stamp}-{customer_id:05d}"
def test_invoice_reference(monkeypatch):
    class FrozenDatetime(datetime):
        @classmethod
        def now(cls, tz=None):
            return datetime(2026, 3, 14, 9, 30, tzinfo=timezone.utc)

    monkeypatch.setattr(billing, "datetime", FrozenDatetime)

    assert billing.invoice_reference(42) == "INV-20260314-00042"
$ pytest -q
.                                                                 [100%]
1 passed in 0.01s

Workable, but the neater fix is a seam in the code — def invoice_reference(customer_id, now=None) — which needs no patching at all. Reach for a monkeypatch when you cannot change the signature, not before.

When the call itself is the assertion

monkeypatch gives you a stub. When what you need to verify is how the dependency was called, use a mock, which records that for you:

from unittest.mock import patch


@patch("weather.requests.get")
def test_request_is_well_formed(mock_get):
    mock_get.return_value = FakeResponse({"temp_c": 11.4})

    weather.current_temperature("Leeds")

    mock_get.assert_called_once_with(
        "https://api.example.com/weather",
        params={"city": "Leeds"},
        timeout=5,
    )
$ pytest -q
.                                                                 [100%]
1 passed in 0.02s

Drop the timeout from weather.py and the assertion reports exactly what changed:

E       AssertionError: expected call not found.
E       Expected: get('https://api.example.com/weather', params={'city': 'Leeds'}, timeout=5)
E       Actual: get('https://api.example.com/weather', params={'city': 'Leeds'})

That is a genuine regression — a request with no timeout can hang forever — and no stub-based test would have caught it.

Mocks accept anything, until you spec them

@patch("weather.send_alert")
def test_alert_on_freezing(mock_alert):
    weather.check_frost(-2.0)
    mock_alert.assert_called_once()
$ pytest -q
.                                                                 [100%]
1 passed in 0.01s

Now change the real send_alert(message) to send_alert(message, level). The test still passes, because a plain Mock accepts any call at all. The production code is broken and the suite is green.

@patch("weather.send_alert", autospec=True)
def test_alert_on_freezing(mock_alert):
    weather.check_frost(-2.0)
    mock_alert.assert_called_once()
$ pytest -q
E       TypeError: missing a required argument: 'level'
1 failed in 0.02s

autospec=True builds the mock from the real signature, so the test fails when the caller and the callee disagree. Use it by default.

pytest-mock

pytest-mock wraps the same machinery in a fixture, which avoids stacked decorators and the argument-ordering they impose:

pip install pytest-mock
def test_alert_on_freezing(mocker):
    alert = mocker.patch("weather.send_alert", autospec=True)
    clock = mocker.patch("weather.datetime", autospec=True)
    clock.now.return_value = datetime(2026, 1, 9, tzinfo=timezone.utc)

    weather.check_frost(-2.0)

    alert.assert_called_once_with("frost warning", level="high")
$ pytest -q
.                                                                 [100%]
1 passed in 0.02s

Every patch is undone at the end of the test, like monkeypatch. With three dependencies to replace, this reads better than three decorators.

What not to mock

Mocking your own internals writes the current call structure into the test, so a refactor that changes nothing observable turns the suite red. The rule that survives contact:

  • Mock at the boundary — HTTP, clock, filesystem, message queue, payment gateway.
  • Do not mock the thing under test, or its pure helpers. Call them.
  • Prefer a fake with real behaviour (an in-memory store) over a mock with assertions when several tests need the same dependency.

A test suite where every test patches five things is usually testing that the code calls the functions it calls.

Practice

1. Patch requests.get to return a 404 and assert the error message.
def test_unknown_city(monkeypatch):
    monkeypatch.setattr(weather.requests, "get",
                        lambda *a, **kw: FakeResponse({}, status=404))

    with pytest.raises(requests.HTTPError, match="404"):
        weather.current_temperature("Atlantis")
.                                                                 [100%]
1 passed in 0.01s

Both branches of raise_for_status are now covered without a single network call.

2. Change an import to from requests import get and watch the patch stop working.
E   requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com', port=443)
1 failed in 5.19s

The five-second timeout is the tell. A patched test that suddenly takes seconds is patching a name nothing calls.

3. Use monkeypatch.setenv in one test and assert the variable is gone in the next.
def test_sets_it(monkeypatch):
    monkeypatch.setenv("WEATHER_API_KEY", "abc")
    assert os.environ["WEATHER_API_KEY"] == "abc"


def test_it_was_undone():
    assert os.environ.get("WEATHER_API_KEY") is None
..                                                                [100%]
2 passed in 0.01s

The second test passes in any order, because the fixture reverses the change at teardown rather than at the start of the next test.

4. Add an argument to a patched function and compare with and without autospec.
# without autospec
1 passed in 0.01s

# with autospec=True
E       TypeError: missing a required argument: 'level'
1 failed in 0.02s

The first result is the dangerous one: a green test for code that raises TypeError in production. autospec costs nothing and removes the failure mode.

Next: temporary files, captured output, and log records — testing the effects code has outside its return value.

Frequently Asked Questions

What is the difference between monkeypatch and unittest.mock?
monkeypatch is a pytest fixture that sets an attribute and undoes it after the test; you supply the replacement. unittest.mock.patch installs a Mock object that also records how it was called, so you can assert on the arguments. Use monkeypatch for a stub, mock when the call itself is the thing under test.
Why does my patch have no effect?
Because you patched where the object is defined rather than where it is looked up. If a module does from requests import get, that module holds its own reference to get, so patching requests.get leaves it untouched. Patch mymodule.get instead.
Do I have to undo a monkeypatch?
No. monkeypatch records every change and reverses it when the test ends, including on failure. That is the main reason to prefer it over setattr by hand, which leaks the change into every test that runs afterwards.
What does autospec=True do?
It builds the mock from the real object's signature, so calling it with the wrong number or names of arguments raises TypeError in the test. Without it, a mock accepts any call, and a test keeps passing after the real function's signature changes.