Advanced · Lesson 66 of 94

Mocking and testing side effects

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

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.

Mocking and testing side effects: code under test → pass a fake object in the test → monkeypatch / patch the target for this test
Mocking and testing side effects · Figure 1 · Open for full size

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.

Mocking and testing side effects: test → your code (real) → boundary: clock · HTTP · file · random → fixed time · canned response · tmp_path · seeded values
Mocking and testing side effects · Figure 2 · Open for full size

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/'
Output
..                                                                       [100%]
2 passed in Ns

Mock 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/'
Output
..                                                                       [100%]
2 passed in Ns

patch 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/'
Output
...                                                                      [100%]
3 passed in Ns

Environment 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/'
Output
..                                                                       [100%]
2 passed in Ns

Try 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

What's next

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.

Course overview