Python Testing #4 mock and monkeypatch: Controlling What You Can't Control

7 min read

A function that reads the current time, draws a random value, or calls an external API gives a different result every time it runs. A test that passed yesterday breaks today; a test that passed on your machine breaks in CI. In this post we’ll cover the two tools for controlling these uncontrollable dependencies inside a test: monkeypatch and unittest.mock.

  • #1 Getting started with pytest
  • #2 Fixtures
  • #3 parametrize and markers
  • #4 mock and monkeypatch ← this post
  • #5 Testing the outside world
  • #6 Test design and coverage
  • #7 CI integration

A function you can’t test as-is #

Suppose you need to test this function.

discount.py: a function that depends on time
from datetime import datetime

def night_discount(price: int) -> int:
    """Orders after 10 PM get a 10% discount."""
    now = datetime.now()
    if now.hour >= 22:
        return int(price * 0.9)
    return price

The result of this function depends on when the test runs. Run it during the day and the discount branch never executes; run it after 10 PM and the other branch goes dead. Random values, environment variables, and network calls create the same problem. There’s only one way out: swap the dependency for a value you choose, for exactly as long as the test runs.

monkeypatch: pytest’s built-in replacement tool #

monkeypatch is a fixture pytest provides out of the box. As we saw in #2 Fixtures, you just take it as a test function argument. The key property: everything it changes is automatically restored when the test ends.

Freezing time with setattr #

test_discount.py: pinning the clock
from datetime import datetime
import discount

class FakeDatetime:
    @classmethod
    def now(cls):
        return datetime(2026, 7, 12, 23, 0)   # frozen at 11 PM

def test_night_discount(monkeypatch):
    monkeypatch.setattr(discount, "datetime", FakeDatetime)
    assert discount.night_discount(10000) == 9000

The shape is monkeypatch.setattr(target object, "attribute name", replacement). We swapped the datetime inside the discount module for a fake, so the current time the function sees is always 11 PM. When the test ends, the real datetime comes back.

setenv and chdir #

Environment variables and the working directory are controlled the same way.

Replacing env vars and directories
def test_api_key_required(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert load_config().api_key == "test-key-123"

def test_missing_key(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    with pytest.raises(KeyError):
        load_config()

def test_reads_local_file(monkeypatch, tmp_path):
    monkeypatch.chdir(tmp_path)               # move into the temp directory
    (tmp_path / "data.txt").write_text("hello")
    assert read_data() == "hello"

Modify os.environ directly and the contamination leaks into the next test; monkeypatch.setenv is isolated per test. It eliminates at the source the entire class of accidents where one environment variable makes results depend on test order.

Mock and MagicMock: fakes that keep records #

With monkeypatch, you have to build the replacement yourself. Mock from the standard library’s unittest.mock is a tool that conjures that replacement on the spot.

Mock's three abilities
from unittest.mock import Mock

fake = Mock()

# 1. return_value: calling it returns a fixed value
fake.get_user.return_value = {"id": 1, "name": "Curtis"}
assert fake.get_user(1) == {"id": 1, "name": "Curtis"}

# 2. Call records: it remembers exactly how it was called
fake.get_user.assert_called_once_with(1)
assert fake.get_user.call_count == 1

# 3. side_effect: raise exceptions, or return different values per call
fake.fetch.side_effect = TimeoutError("connection failed")
fake.parse.side_effect = [10, 20, 30]   # returned in call order

Put an exception in side_effect and you can test “how does our code react when the external service is down” — a one-liner that manufactures failure scenarios that are nearly impossible to reproduce for real. MagicMock is Mock plus support for magic methods like __len__ and __iter__, and it’s also what patch creates by default. In everyday use, you can treat the two as interchangeable.

The big patch trap: patch where it’s used, not where it’s defined #

unittest.mock.patch replaces the object at the path you specify with a MagicMock. But here lies the trap that catches half of all first-time mock users.

weather.py: imports requests.get and uses it
from requests import get

def today_temp(city: str) -> float:
    resp = get(f"https://api.example.com/weather/{city}")
    return resp.json()["temp"]

What happens if you test this code while patching requests.get?

🚫 Patched, yet a real network call goes out
from unittest.mock import patch

def test_today_temp_wrong():
    with patch("requests.get") as fake_get:        # ✗ no effect
        today_temp("seoul")                         # real API call happens

weather.py used from requests import get, which copied the function into its own module namespace. Replacing requests.get does nothing to the copy named weather.get, which still points at the real function. The patch target must be the place where the code under test looks the name up — not the place where the name was defined.

✅ Patch where it's used
from unittest.mock import patch

def test_today_temp():
    with patch("weather.get") as fake_get:
        fake_get.return_value.json.return_value = {"temp": 28.5}
        assert today_temp("seoul") == 28.5
        fake_get.assert_called_once_with("https://api.example.com/weather/seoul")

fake_get.return_value is “the response object the fake get returned,” and we then specify what that object’s json() should return. The assert_called_once_with on the last line verifies it was called exactly once, with exactly that URL. Use assert_not_called when there should have been no call at all, and assert_any_call when matching any one of several calls is enough.

You can do the same job with monkeypatch. It also accepts a string path, as in monkeypatch.setattr("weather.get", fake_get), so if you want to stay in pytest style, the monkeypatch + Mock combination is a natural fit.

More than three mocks? Question the design #

Mocks are as easy to abuse as they are convenient. The warning sign is unmistakable.

🚫 A test that transcribes the implementation
def test_order_process():
    with patch("shop.db.get_user") as m1, \
         patch("shop.db.get_cart") as m2, \
         patch("shop.payment.charge") as m3, \
         patch("shop.mailer.send") as m4:
        m1.return_value = ...
        m2.return_value = ...
        process_order(user_id=1)
        m3.assert_called_once()
        m4.assert_called_once()

This test doesn’t verify “is the order processed correctly” — it verifies that the function calls these four things internally, in this order. Refactor the internal call structure while keeping behavior identical, and the test breaks. The test has become coupled to the implementation. Once you’re past three mocks, read it as a design signal that the function is doing too much. The baseline principle: mock only the external boundaries (network, time, randomness), and test everything else with real objects.

fake instead of mock: building your own stand-in #

When mock setup gets long, there’s another option — write a simple fake implementation yourself.

✅ A fake repository
class FakeUserRepo:
    def __init__(self):
        self.users: dict[int, str] = {}

    def save(self, user_id: int, name: str) -> None:
        self.users[user_id] = name

    def find(self, user_id: int) -> str | None:
        return self.users.get(user_id)

def test_register():
    repo = FakeUserRepo()
    register_user(repo, user_id=1, name="Curtis")
    assert repo.find(1) == "Curtis"

Because a fake is a minimal implementation that actually works, you end up verifying resulting state rather than call order. It survives refactoring, gets reused across tests, and spares readers from decoding setup code. When the call itself is what you’re verifying (sending an email, charging a payment), mock fits; when the collaborator exchanges data, fake fits.

Recap #

What we covered in this post:

  • Functions depending on time, randomness, or external APIs can’t be tested as-is, so we swap those dependencies out for the duration of the test
  • monkeypatch replaces things via setattr, setenv, and chdir, and restores everything automatically when the test ends
  • Mock fixes return values with return_value, produces exceptions and sequential returns with side_effect, and records every call
  • patch must target where the name is used, not where it’s defined
  • Verify how things were called with assert_called_once_with and call_count
  • More than three mocks is a design signal that the test is coupled to the implementation
  • For collaborators that exchange data, a fake implementation beats a mock for refactoring resilience

In the next post (#5 Testing the outside world), we’ll take one more step out and cover strategies for testing code that touches the real outside world — databases and HTTP APIs. The markers from #3 parametrize and markers will reappear there for separating out slow tests.

X