What you'll learn
- Explain why code that touches time, files, randomness, or the network is hard to test, and the two ways to fix it
- Replace a dependency for one test with
monkeypatch - Use
unittest.mock.Mockandpatchto stand in for objects and check how they were called - Prefer injecting dependencies, and mock at the boundary when you cannot
Concept
A pure function is easy to test: same input, same output. Real code also reads the clock, calls an API, writes files, and rolls dice, and each of those makes a test slow, flaky, or impossible. The fix is to control the dependency during the test. There are two ways. Design the code to receive its dependencies, as the composition lesson taught, and pass a fake in the test. Or, when the code reaches out to a module or global directly, replace that target for the duration of the test. The first is cleaner; the second is what you do with code you did not design.

monkeypatch is the pytest fixture for the second approach. monkeypatch.setattr(module, "name", replacement) swaps an attribute for the test and restores it afterwards, setenv sets an environment variable, setitem changes a dictionary entry, and chdir changes the working folder. The replacement can be a plain function or value. It is the simplest tool for making time.time() return a fixed number or making a module's fetch return canned data.
| Tool | Use when |
|---|---|
| pass a fake object as a parameter | the code accepts its dependency; the cleanest option |
monkeypatch.setattr(mod, "f", fake) |
the code calls mod.f directly; swap it for one test |
monkeypatch.setenv("KEY", "v") |
the code reads configuration from the environment |
Mock() / MagicMock() |
you need an object that records calls and returns what you say |
patch("pkg.module.name") |
context manager or decorator form of replacing an attribute |
mock.assert_called_once_with(...) |
verify the interaction, not only the result |
side_effect=Exception(...) |
make the fake raise, to test error paths |
unittest.mock.Mock builds an object that accepts any attribute access and any call, records everything, and returns what you configure with return_value or side_effect. Afterwards, assert_called_once_with and call_args let the test verify the interaction: that the payment gateway was asked to charge the right amount, exactly once. patch("module.name") replaces a target with a Mock inside a with block or for a decorated test; the target string is where the name is looked up, which is the module that uses it, not the module that defines it. Getting that path right is the single most common mocking mistake.
Mock the boundary, not the middle. A test that mocks the HTTP client and checks the resulting object is testing your code. A test that mocks three of your own functions to test a fourth is testing that your functions call each other, which breaks on every refactor and proves nothing. Fakes should stand in for things outside your control: time, network, filesystem, randomness, the database. Within your code, prefer real objects.

Code
The code under test: a reminder service that reads the clock and sends through a notifier. The clock is a module-level call (hard-wired); the notifier is injected.
# reminders/__init__.py
# reminders/service.py
import time
class Reminder:
def __init__(self, notifier, due_at, message):
self.notifier = notifier
self.due_at = due_at
self.message = message
self.sent = False
def check(self):
now = time.time()
if not self.sent and now >= self.due_at:
self.notifier.send(self.message)
self.sent = True
return True
return False
# pyproject.toml
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
Injecting a fake notifier and monkeypatching the clock. The fake records calls; monkeypatch makes time.time return whatever the test needs.
# tests/test_reminder.py
import time
from reminders.service import Reminder
class FakeNotifier:
def __init__(self):
self.sent = []
def send(self, message):
self.sent.append(message)
def test_not_due_yet(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 1000.0)
notifier = FakeNotifier()
reminder = Reminder(notifier, due_at=2000.0, message="stand up")
assert reminder.check() is False
assert notifier.sent == []
def test_sends_once_when_due(monkeypatch):
monkeypatch.setattr(time, "time", lambda: 2000.0)
notifier = FakeNotifier()
reminder = Reminder(notifier, due_at=2000.0, message="stand up")
assert reminder.check() is True
assert reminder.check() is False
assert notifier.sent == ["stand up"]
# run_reminder.sh
pytest -q tests/test_reminder.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
.. [100%]
2 passed in NsMock records calls and can raise on demand. The test verifies the interaction with the notifier and checks the error path without a real failure.
# tests/test_mock.py
from unittest.mock import Mock
import pytest
from reminders.service import Reminder
def test_notifier_called_with_message(monkeypatch):
monkeypatch.setattr("time.time", lambda: 5000.0)
notifier = Mock()
Reminder(notifier, due_at=10.0, message="drink water").check()
notifier.send.assert_called_once_with("drink water")
def test_notifier_failure_propagates(monkeypatch):
monkeypatch.setattr("time.time", lambda: 5000.0)
notifier = Mock()
notifier.send.side_effect = ConnectionError("gateway down")
reminder = Reminder(notifier, due_at=10.0, message="x")
with pytest.raises(ConnectionError):
reminder.check()
assert reminder.sent is False
assert notifier.send.call_count == 1
# run_mock.sh
pytest -q tests/test_mock.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
.. [100%]
2 passed in Nspatch at the right target. The module under test imports fetch_rate into its own namespace, so the patch must name pricing.fetch_rate, not the module that defines it. The wrong target leaves the real function in place.
# reminders/rates.py
def fetch_rate(currency):
raise RuntimeError("network call: not allowed in tests")
# reminders/pricing.py
from reminders.rates import fetch_rate
def price_in(currency, amount_eur):
return round(amount_eur * fetch_rate(currency), 2)
# tests/test_patch.py
from unittest.mock import patch
import pytest
from reminders.pricing import price_in
def test_patch_where_it_is_used():
with patch("reminders.pricing.fetch_rate", return_value=1.1) as fake:
assert price_in("USD", 100) == 110.0
fake.assert_called_once_with("USD")
def test_patch_where_it_is_defined_does_not_help():
with patch("reminders.rates.fetch_rate", return_value=1.1):
with pytest.raises(RuntimeError, match="network call"):
price_in("USD", 100)
@patch("reminders.pricing.fetch_rate", return_value=2.0)
def test_decorator_form(fake_fetch):
assert price_in("GBP", 50) == 100.0
assert fake_fetch.call_args.args == ("GBP",)
# run_patch.sh
pytest -q tests/test_patch.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
... [100%]
3 passed in NsEnvironment and randomness under control: setenv for configuration and a seeded or patched random for reproducible outcomes.
# reminders/config.py
import os
import random
def greeting():
name = os.environ.get("USER_NAME", "friend")
return f"{random.choice(['Hi', 'Hello', 'Hey'])}, {name}"
# tests/test_env_random.py
import random
from reminders.config import greeting
def test_uses_env_name(monkeypatch):
monkeypatch.setenv("USER_NAME", "Ada")
monkeypatch.setattr(random, "choice", lambda options: options[0])
assert greeting() == "Hi, Ada"
def test_default_name(monkeypatch):
monkeypatch.delenv("USER_NAME", raising=False)
random.seed(1)
first = greeting()
random.seed(1)
assert greeting() == first
assert first.endswith(", friend")
# run_env.sh
pytest -q tests/test_env_random.py 2>&1 | sed -E 's/ in [0-9.]+s/ in Ns/'
.. [100%]
2 passed in NsTry it yourself
Write a weather.py module with fetch_forecast(city) that would call a network API (make it raise if actually called) and advice(city) that returns "umbrella" or "sunglasses" from the forecast and the current hour via time.localtime. Test advice with patch at the correct target for the fetch, monkeypatch for the clock, and a Mock whose side_effect raises to check that a network failure surfaces as your own WeatherError.
Common mistakes
- Patching where the name is defined instead of where it is used. The module under test already holds its own reference; patch
module_under_test.name. - Mocking your own code. Fakes belong at the boundary; mocking internal functions tests call graphs, not behavior, and breaks on refactors.
- Forgetting to assert the interaction. A
Mockaccepts anything silently; checkassert_called_once_withwhen the call itself is the behavior.
What's next
- Control dependencies in tests by injecting fakes or, when the code is hard-wired, by replacing the target with
monkeypatchorpatch. Mockrecords calls, returns configured values, and can raise; assert on the interaction when it matters.- Patch where the name is used, and mock only the boundary: time, network, files, randomness.
You can test anything your code touches. Next you will decide what to test, how to organize it, and how to measure it honestly: testing strategy and coverage.