Article summary

TL;DR

  • mocker comes from pytest-mock and restores patches after each test; keep the behavior under test real.
  • For a patched class, configure methods on its return_value, the instance your code receives.
  • autospec=True catches incorrect method calls; spec_set=True also rejects unknown attribute assignments. Neither validates your chosen return values.
  • Patch the name the calling code looks up. Decorators require extra care because they are applied when a function is defined.
  • Keep tests with the real dependency to check connections that mocked tests cannot verify.

01 / MOCKING BASICS

What mocking changes in a Python test

A test can pass because your code works. It can also pass because a mock quietly accepts a method call that the real object would reject.

This pytest mocking tutorial starts with a small, useful test: adding $100 to a wallet should increase its euro balance by €90 when the exchange rate is 0.90. We’ll control the rate with mocker, then explore class mocks, mock drift, patching targets, and decorators. Each step addresses a different reason a green test might give you false confidence.

A mock is a configurable replacement for a dependency in a test. A dependency is something your code calls or reads to do its job: another object, a file, an API, or a database. You can tell a mock what to return, make it raise an exception, and check how the code called it. Patching temporarily replaces an existing name or attribute with that substitute.

The useful boundary is the responsibility you want to test. For our wallet, keep the balance calculation real and control the exchange-rate provider. If we replaced the balance calculation itself, a passing test would tell us very little about the wallet.

Learn Python test automation from the basics to advanced pytest techniques. Explore the course and enroll.

View course

02 / THE MOCKER FIXTURE

Start with the pytest-mock mocker fixture

pytest-mock is a pytest plugin that provides the mocker fixture. It wraps Python’s built-in unittest.mock tools and automatically undoes patches at the end of a test. You request it by adding mocker to the test function’s parameters; you don’t import a variable named mocker.

Use the runnable wallet example to follow along. With uv installed, run:

git clone https://github.com/artem-istranin/istranin-dev-code-examples.git
cd istranin-dev-code-examples/pytest-mocking-tutorial
uv sync --locked
uv run pytest -q

The project uses Python 3.13+, with pytest 9.1.1 and pytest-mock 3.15.1 pinned in its lockfile. Run the commands from the example directory. If you’re adding the plugin to your own existing uv project, use uv add --dev pytest pytest-mock.

The example has two main parts:

  • wallet/main.py contains Wallet, which maintains a balance in euros.
  • wallet/fx.py contains RateProvider, which reads exchange rates from a JSON file. get_rate("USD", base="EUR") returns the multiplier for converting dollars into euros.

The local file keeps the example runnable without API credentials or network access. It plays the same dependency role that a remote rate service could play in a larger application. A sample file contains {"USD/EUR": "0.90"}; these are illustrative values, not current exchange rates.

Here is the wallet’s main behavior:

# wallet/main.py
from decimal import Decimal
from pathlib import Path

from wallet.fx import RateProvider


class Wallet:
    def __init__(self, rates_path: Path):
        self.rates = RateProvider(rates_path)
        self._balance = Decimal("0")

    def add(self, amount: Decimal, currency: str) -> None:
        rate = (
            Decimal("1")
            if currency == "EUR"
            else self.rates.get_rate(currency, base="EUR")
        )
        self._balance += amount * rate

    def balance(self) -> Decimal:
        return self._balance

Decimal represents decimal amounts without the binary floating-point surprises of values such as 0.1. We construct it from strings. This small example expects uppercase currency codes and leaves production concerns such as rounding policies and transaction storage out of scope.

For the first test, replace only the provider’s rate lookup:

# tests/test_wallet.py
from decimal import Decimal

from wallet.fx import RateProvider
from wallet.main import Wallet


def test_add_dollars_with_patched_method(mocker, tmp_path):
    get_rate = mocker.patch.object(
        RateProvider, "get_rate", return_value=Decimal("0.90")
    )
    wallet = Wallet(tmp_path / "unused.json")

    wallet.add(Decimal("100"), "USD")

    assert wallet.balance() == Decimal("90")
    get_rate.assert_called_once_with("USD", base="EUR")

mocker.patch.object takes an object and the name of an attribute to replace. Here, it replaces RateProvider.get_rate. return_value is the value the replacement returns when called, so the test supplies a known rate of 0.90.

tmp_path is pytest’s fixture for a temporary directory unique to the test. No file is created here. The real provider’s constructor only remembers the path, and the patched method never reads it.

The balance assertion checks the wallet’s result. The call assertion checks a meaningful detail at the dependency boundary: it requested the USD-to-EUR rate exactly once. Avoid asserting every internal helper call just because a mock can record them.

Run this first example on its own:

uv run pytest tests/test_wallet.py::test_add_dollars_with_patched_method -q

If pytest reports fixture 'mocker' not found, check that pytest-mock is installed in the environment running the tests. In this project, uv sync --locked installs it and uv run pytest uses that environment.

Leonardo Giordani illustrates the same idea in Clean Architectures in Python. Here, the web layer is the component being tested. A “use case” is the application operation it calls, such as searching for available rooms. The test controls that operation’s response while checking how the web layer handles requests and produces responses.

Test inputs and outputs surround the web framework while the use case and database remain outside the tested component

Illustration: “Testing the web layer in isolation,” from Clean Architectures in Python, Chapter 1, by Leonardo Giordani.

You don’t need a web framework or a full layered architecture to apply this idea. Start by deciding which behavior stays real and which dependency the test controls.

03 / CLASSES AND FAILURES

Mock a class and simulate a dependency failure

The first test still constructs a real RateProvider. Sometimes construction itself opens a connection or loads expensive data. To avoid that work, replace the whole class before creating the wallet. The next two tests go in tests/test_wallet.py and reuse its existing imports:

def test_add_dollars_with_patched_class(mocker, tmp_path):
    provider_class = mocker.patch("wallet.main.RateProvider")
    provider = provider_class.return_value
    provider.get_rate.return_value = Decimal("0.90")
    wallet = Wallet(tmp_path / "unused.json")

    wallet.add(Decimal("100"), "USD")

    assert wallet.balance() == Decimal("90")
    provider.get_rate.assert_called_once_with("USD", base="EUR")

mocker.patch takes a dotted target string. Use wallet.main.RateProvider here; we’ll trace why that is the correct target shortly.

There are two different calls to configure. The wallet first calls RateProvider(rates_path) to obtain a provider object. It then calls that object’s get_rate(...) method. The class mock creates a mock return value automatically. Calling the patched class returns that same object, so we configure its get_rate method before creating the wallet.

Configuration What it controls
provider_class.return_value The object returned when the patched class is called
provider.get_rate.return_value The rate returned when that object’s method is called

Setting provider_class.get_rate.return_value would configure a method on the class mock, not on the instance the wallet receives.

By default, patching an ordinary class creates a MagicMock. Like Mock, it records calls and lets you configure results; it also supports Python protocols through special methods, such as those used by context managers. You don’t need to construct either directly for this example.

Use side_effect to exercise the failure path

Our provider raises RateUnavailable when it cannot supply a usable rate. The wallet should let that exception reach its caller and preserve the balance it already had. side_effect lets the test trigger this situation deliberately:

import pytest

from wallet.fx import RateUnavailable


def test_missing_rate_leaves_existing_balance_unchanged(mocker, tmp_path):
    provider_class = mocker.patch("wallet.main.RateProvider")
    provider_class.return_value.get_rate.side_effect = RateUnavailable("Offline")
    wallet = Wallet(tmp_path / "unused.json")
    wallet.add(Decimal("20"), "EUR")

    with pytest.raises(RateUnavailable, match="Offline"):
        wallet.add(Decimal("100"), "USD")

    assert wallet.balance() == Decimal("20")

Adding euros requires no lookup, so the initial deposit succeeds. The dollar deposit then triggers the configured exception. pytest.raises checks that it occurs, and the final assertion checks that the failure didn’t corrupt the balance.

Besides exceptions, side_effect accepts a callable or a sequence of results. Those are useful for input-dependent responses or retries, but a fixed return value or one exception is usually the clearest starting point.

04 / MOCK DRIFT

Prevent mock drift with autospec and spec_set

Mock drift happens when a test’s replacement no longer matches the dependency it represents. A permissive mock can accept nonexistent methods or incorrect arguments, allowing tests to pass against an interface the real object doesn’t have.

The provider’s real method has this signature:

def get_rate(self, currency: str, *, base: str) -> Decimal:

The * makes base a keyword-only argument. A valid call is get_rate("USD", base="EUR").

Suppose a developer accidentally changes the call in Wallet.add to:

self.rates.get_rate(currency, target="EUR")

The real provider rejects target with TypeError. An unrestricted mock with a configured return value accepts it. A test that checks only the resulting balance can stay green. Our earlier assert_called_once_with would catch this particular mistake, but we shouldn’t rely on remembering a call assertion for every method.

Add autospec=True and spec_set=True to the class patch:

provider_class = mocker.patch(
    "wallet.main.RateProvider", autospec=True, spec_set=True
)
provider = provider_class.return_value
provider.get_rate.return_value = Decimal("0.90")

autospec builds the mock from the real class’s interface. Its methods check call signatures, so the incorrect target= call fails. Trying to call a nonexistent method also fails. spec_set additionally prevents assigning attributes that the specification doesn’t contain, which catches mistakes while configuring the mock.

Use this stricter patch in both class-based wallet tests. The repository already contains that version. It keeps their assertions focused on the balance and important provider interactions while adding automatic interface checks.

This is autospec=True, not pytest’s autouse=True. The latter makes a fixture run without being named as a test parameter; it does not validate mocks.

What autospec cannot guarantee

Autospeccing checks names and signatures, not the correctness of a configured result. It doesn’t enforce return type annotations. You can still configure the wrong exchange rate, misunderstand which currency a rate refers to, or return data that a real service never produces.

There is also a practical limit when autospeccing classes: attributes created only inside __init__ may not be visible in the class specification. Our tests configure get_rate, a method defined on the class, so they don’t need the instance’s path attribute. See Python’s autospeccing documentation when applying this to more complex objects.

Stricter mocks reduce interface mistakes. Tests using the real dependency are still needed to check whether the components actually work together. We’ll add that connection check after dealing with two common patching problems.

05 / PATCHING TARGETS

Choose the patch target where the name is looked up

A patch can be valid Python and still change the wrong thing. The key question is: which name will the code under test look up when it runs?

Our provider is defined in wallet/fx.py, but the wallet imports it into another module:

# wallet/main.py
from wallet.fx import RateProvider

# Inside Wallet.__init__:
self.rates = RateProvider(rates_path)

After that import, both modules have a name pointing to the original class. Replacing wallet.fx.RateProvider changes the name in wallet.fx; it doesn’t update the existing name in wallet.main.

The constructor call looks up the name in wallet.main, so replace that one:

mocker.patch("wallet.main.RateProvider", autospec=True, spec_set=True)

Python’s documentation calls this rule where to patch.

Different import styles lead to different lookups. Here are two alternatives, not changes you need to make to the example:

Import and use in the calling module Target to replace the class
from wallet.fx import RateProvider, then RateProvider(...) wallet.main.RateProvider
import wallet.fx, then wallet.fx.RateProvider(...) wallet.fx.RateProvider
from wallet import fx, then fx.RateProvider(...) wallet.fx.RateProvider

This also explains why the first patch.object(RateProvider, "get_rate", ...) test worked. It changed a method on the shared class object. The later class patch replaces one module’s name with a different object. Those operations affect different things.

When a patch appears ineffective, trace the lookup from the calling code. Check aliases and re-exports, and create the wallet after applying the class patch. An instance created beforehand already holds its provider.

For code you control, another option is dependency injection: accepting a provider object as a constructor argument. The test can then pass a substitute directly instead of replacing an imported name. That can simplify the design when a dependency already needs multiple implementations; you don’t need a dependency-injection framework to do it.

06 / PATCHING DECORATORS

Patching decorators: the import has already done some work

A Python decorator takes a function and returns the callable that will be used in its place. A common pattern is a wrapper: it performs work before or after calling the original function.

Let’s add reporting around an application operation. Our report decorator calls the function, then reports successful completion:

# wallet/decorators.py
from functools import wraps

from wallet.events import report_event


def report(func):
    @wraps(func)
    def wrapped(*args, **kwargs):
        result = func(*args, **kwargs)
        report_event(f"{func.__name__} completed")
        return result

    return wrapped

*args and **kwargs pass the caller’s positional and keyword arguments through to the original function. wraps preserves useful metadata, such as its name, and exposes the original function through __wrapped__.

In this example, report_event simply prints the event. Keeping that destination in a separate function gives tests one place to substitute if reporting later sends events elsewhere.

Apply the decorator to add_income, an operation that adds money and returns the new balance:

# wallet/reported.py
from decimal import Decimal

from wallet.decorators import report
from wallet.main import Wallet


@report
def add_income(wallet: Wallet, amount: Decimal, currency: str) -> Decimal:
    wallet.add(amount, currency)
    return wallet.balance()

Python applies @report when it defines add_income, which happens while importing this module. Conceptually, it performs add_income = report(add_income). From then on, calling add_income calls the installed wrapper.

Therefore, this sequence is too late to bypass reporting:

from wallet.reported import add_income

# Inside a test: this will not remove the wrapper already on add_income.
mocker.patch("wallet.decorators.report", new=lambda func: func)

The replacement decorator would return a function unchanged. But the original decorator has already done its work, and changing its name doesn’t unwrap existing functions.

Usually, patch what the wrapper calls

If the goal is to avoid sending an event, leave the wrapper in place and replace its event destination:

from wallet.reported import add_income


def test_reports_success_without_replacing_the_decorator(mocker, tmp_path):
    report_event = mocker.patch("wallet.decorators.report_event", autospec=True)
    wallet = Wallet(tmp_path / "unused.json")

    assert add_income(wallet, Decimal("20"), "EUR") == Decimal("20")
    report_event.assert_called_once_with("add_income completed")

The wrapper looks up report_event when it runs, so this patch works even though the decorated function is already imported. The target is wallet.decorators.report_event, because the decorator module imported that name directly.

We use euros here to keep the test about reporting; euros require no rate lookup. The repository also checks that a failed foreign-currency deposit doesn’t report completion.

Test the undecorated body when that is the intended boundary

Because our decorator uses functools.wraps, a test that has already created a wallet can explicitly call the original function:

result = add_income.__wrapped__(wallet, Decimal("20"), "EUR")
assert result == Decimal("20")

This bypasses this wrapper; it doesn’t test the public decorated operation. With multiple decorators, __wrapped__ typically removes one layer at a time, provided the decorators preserve that attribute. See the functools.wraps documentation.

For reporting, authorization, transactions, or retries, keep separate tests that exercise the actual decorated behavior. A test of the underlying body cannot establish that those surrounding guarantees work.

If you must replace the decorator, patch before the first import

This is an advanced case for testing import-time behavior; patching the wrapper’s dependency is sufficient for most tests. Occasionally you need to stop a decorator from being applied at all. The order must be: patch the decorator, then import the module that uses it.

Moving the import inside a fixture is insufficient if another test has already imported that module. Python caches imported modules in sys.modules, and pytest may import test modules during collection, before fixtures run. A later import can simply return the cached module with its existing wrapper.

The companion example demonstrates the sequence in a fresh Python process. This is the code that process runs:

from decimal import Decimal
from pathlib import Path
from unittest.mock import patch

with patch("wallet.decorators.report", new=lambda func: func):
    from wallet.reported import add_income

from wallet.main import Wallet

wallet = Wallet(Path("unused.json"))
assert add_income(wallet, Decimal("20"), "EUR") == Decimal("20")

There is no mocker fixture in this child process, so it uses unittest.mock.patch directly. The context manager restores the decorator name when the block ends. The imported add_income remains undecorated in that process because the wrapper was never installed.

tests/test_import_time_patch.py launches this code with subprocess.run and sys.executable, the current environment’s Python interpreter. It checks a successful exit and no printed completion event. The child process exits afterward, discarding its module cache, so it cannot affect other tests.

Run the decorator examples with:

uv run pytest tests/test_reporting.py tests/test_import_time_patch.py -v

Use this isolated import test when the import-time behavior is what you need to examine. For ordinary application tests, patching the wrapper’s dependency is easier to maintain.

07 / REAL DEPENDENCIES

Keep a test that uses the real provider

The mock tests establish what the wallet does with the rates and failures we supply. They don’t establish that RateProvider can read the file, find the right currency pair, and return the expected type.

For that, use a real temporary file and no mocks:

# tests/test_real_rates.py
from decimal import Decimal

from wallet.main import Wallet


def test_wallet_reads_real_rates(tmp_path):
    rates_path = tmp_path / "rates.json"
    rates_path.write_text('{"USD/EUR": "0.90"}', encoding="utf-8")
    wallet = Wallet(rates_path)

    wallet.add(Decimal("100"), "USD")
    wallet.add(Decimal("20"), "EUR")

    assert wallet.balance() == Decimal("110")

This checks the connection between the wallet and its actual file-backed provider. If the provider’s JSON lookup breaks, the test can fail even while tests with a mocked provider remain green. The repository also covers missing and unusable rates without mocking.

For a dependency this small, real-file tests may be all you need for many cases. Mocking becomes more valuable when you need precise failure conditions or when the dependency is costly, remote, or hard to set up. Choose the boundary based on the behavior and risk you need to check; the guide to unit, integration, and E2E testing explains how these tests fit together.

If you replace the file provider with an HTTP service later, an autospecced client still won’t tell you whether the service’s response matches your assumptions. Keep tests for the real client and its expected response format, and choose appropriate integration or contract checks for the service boundary.

For your next test, pick one behavior, keep its implementation real, and replace only the dependency that makes the scenario difficult to arrange. Check the result, verify important boundary calls, and keep a real integration test alongside it. If the test needs a long chain of patches to reach that behavior, revisit the design using these Python unit testing best practices.