Module 2: Unit Testing and Mocking

pytest-mock: the `mocker` fixture

unittest.mock (capsule 03) works, but it has operational friction:

  • Decorator stacking: multiple @patches stacked, args in reverse order
  • Manual cleanup: if you use patch.start(), remember stop() in a finally
  • Verbose imports: from unittest.mock import patch, MagicMock in every test file
  • Poor integration with pytest fixtures: patches aren't naturally fixtures

pytest-mock solves this with a fixture called mocker. It's a wrapper over unittest.mock with automatic cleanup and natural pytest integration. In almost every professional project that uses pytest, pytest-mock is the default over plain unittest.mock.

In this capsule you'll learn how mocker.patch replaces stacked @patches, how mocker.spy lets you verify without mocking (important for cases where you want the real function to run but you still want to assert on the calls), and how pytest-mock integrates with the features you learned in M01 (fixtures, parametrize, conftest).

By the end, you'll be using mocker.patch instead of @patch decorators in 95% of cases.


Setup

pytest-mock has been in your pyproject.toml since capsule 01 of the module:

test = [
    "pytest>=8.2",
    "pytest-mock>=3.12",  # ← this one
    ...
]

Verify it's installed:

$ pip show pytest-mock
Name: pytest-mock
Version: 3.14.0

pytest-mock activates automatically once installed — no registration required. Your test can request the mocker fixture directly.


The change: @patchmocker.patch

Before (plain unittest.mock)

from unittest.mock import patch


@patch("app.services.notifications.record_event")
@patch("app.services.notifications.send_sms")
@patch("app.services.notifications.send_email")
def test_notify_via_email(mock_send_email, mock_send_sms, mock_record):
    # ⚠️ Args in REVERSE order from the decorators
    user = make_user(channel="email")
    notify_user_about_charge(user, amount=100, charge_id="ch_x")

    mock_send_email.assert_called_once()
    mock_send_sms.assert_not_called()
    mock_record.assert_called_once()

After (pytest-mock)

def test_notify_via_email(mocker):
    mock_send_email = mocker.patch("app.services.notifications.send_email")
    mock_send_sms = mocker.patch("app.services.notifications.send_sms")
    mock_record = mocker.patch("app.services.notifications.record_event")

    user = make_user(channel="email")
    notify_user_about_charge(user, amount=100, charge_id="ch_x")

    mock_send_email.assert_called_once()
    mock_send_sms.assert_not_called()
    mock_record.assert_called_once()

Visible differences

unittest.mock                       pytest-mock

3 stacked decorators                3 lines inside the test
Args in reverse order (confusing)   You assign mocks as variables
Imports: patch                      Imports: none (mocker is a fixture)
Automatic cleanup (decorator)       Automatic cleanup (pytest fixture)

Main benefit: declaration order = usage order. No surprises with inverted args.

The mocker.patch API

mocker.patch accepts the same arguments as unittest.mock.patch:

def test_x(mocker):
    # Basic patch
    mock_func = mocker.patch("module.func")

    # With return_value
    mock_func = mocker.patch("module.func", return_value=42)

    # With side_effect
    mock_func = mocker.patch("module.func", side_effect=ValueError("boom"))

    # With spec
    mock_class = mocker.patch("module.MyClass", autospec=True)

    # Patch.object equivalent
    mock_method = mocker.patch.object(SomeClass, "method")

    # Patch.dict equivalent
    mocker.patch.dict("os.environ", {"API_KEY": "test"})

Almost everything you know about unittest.mock.patch applies directly to mocker.patch — only the way you invoke it changes.


mocker.MagicMock and mocker.Mock

To create mocks without patching (standalone objects), mocker also provides shortcuts:

def test_x(mocker):
    # Equivalent to unittest.mock.MagicMock()
    fake_user = mocker.MagicMock()
    fake_user.email = "alice@example.com"
    fake_user.id = 1

    # Equivalent to unittest.mock.Mock()
    fake_request = mocker.Mock()

    # Equivalent to AsyncMock()
    fake_async = mocker.AsyncMock()

These don't patch anything — they just create mocks. Useful when you're building objects to pass into the function under test (not to replace dependencies in namespaces).

def test_calculate_total(mocker):
    # Build mock objects to pass into the function
    items = [
        mocker.MagicMock(price=Decimal("10"), quantity=2),
        mocker.MagicMock(price=Decimal("5"), quantity=3),
    ]

    total = calculate_total(items)
    assert total == Decimal("35")

mocker.spy: verifying without mocking

Sometimes you want the function to actually run but you also want to verify it was called. That's what spy is for.

Use case: a pure function you want to verify was called

# app/services/orders.py
from app.services.email import send_email
from app.helpers import format_order_summary  # a pure function


def confirm_order(order):
    summary = format_order_summary(order)
    send_email(order.user.email, summary)

In the test, you want to:

  • Mock send_email (you don't send a real email)
  • Spy format_order_summary (you want it to actually run, but you want to verify it was called with order)
def test_confirm_order(mocker):
    mock_send = mocker.patch("app.services.orders.send_email")
    spy_format = mocker.spy("app.services.orders", "format_order_summary")

    order = make_order(user_email="alice@example.com")
    confirm_order(order)

    # spy_format ran the real code, but it recorded the call
    spy_format.assert_called_once_with(order)
    mock_send.assert_called_once()
    # The email was "sent" with the real formatting
    sent_to, sent_body = mock_send.call_args.args
    assert sent_to == "alice@example.com"
    assert "Order summary" in sent_body  # from the real format

spy is valuable when the spied function's code is trivial but you want to verify it was invoked. It saves you from mocking pure functions unnecessarily.

mocker.spy on an object's method

def test_calculate_total_calls_apply_discount(mocker):
    calculator = OrderCalculator()
    spy_discount = mocker.spy(calculator, "apply_discount")

    calculator.calculate_total(items, discount_percent=10)

    spy_discount.assert_called_once_with(items, 10)

A spy on a specific method — the method still runs for real, but it records calls.

spy vs patch

patch
  → Replaces the function with a Mock
  → The Mock doesn't run the real code
  → You configure return_value/side_effect
  → Useful for boundaries (DB, APIs)

spy
  → The function runs the real code
  → It records the calls for assert_called_with, etc.
  → You don't configure return_value (the real function determines it)
  → Useful for pure functions or internal methods

Automatic cleanup: the invisible benefit

One of the best things about mocker is what you don't have to do: cleanup. Compare:

unittest.mock, manual

def test_x():
    patcher = patch("module.func")
    mock_func = patcher.start()
    try:
        # ... test ...
    finally:
        patcher.stop()  # ← MANDATORY; if it doesn't run, you leak the patch

If you forget stop(), the patch persists into other tests. If an exception fires before the stop, it stays dangling too. A latent bug.

pytest-mock, automatic

def test_x(mocker):
    mock_func = mocker.patch("module.func")
    # ... test ...
    # automatic cleanup when the test finishes (pytest teardown)

If the test fails with an exception, pytest-mock still cleans up. Impossible to forget.

This matters in large suites: with 500 tests using 3-5 mocks each, a single leak can cause cascading failures that are very hard to debug. pytest-mock eliminates that whole bug category by construction.


Integration with pytest fixtures

Pattern: a fixture that mocks + shared setup

# tests/conftest.py
@pytest.fixture
def mock_stripe(mocker):
    """Stripe mocked for all tests."""
    mock = mocker.patch("app.services.billing.stripe_client", autospec=True)
    mock.charges.create.return_value = MagicMock(id="ch_default")
    return mock
# tests/unit/test_billing.py
def test_charge_uses_correct_amount(mock_stripe):
    charge_customer(amount=100)
    mock_stripe.charges.create.assert_called_once_with(amount=100, currency="usd")


def test_charge_returns_charge_id(mock_stripe):
    mock_stripe.charges.create.return_value.id = "ch_specific_test"
    result = charge_customer(amount=100)
    assert result == "ch_specific_test"

The mock_stripe fixture is reused across multiple tests. Each test can modify the behavior (overriding the specific return_value) without affecting the others.

Pattern: a complex fixture with several mocks

# tests/conftest.py
@pytest.fixture
def billing_mocks(mocker):
    """Mocks every billing dependency in a single fixture."""
    return {
        "stripe": mocker.patch("app.services.billing.stripe_client", autospec=True),
        "email": mocker.patch("app.services.billing.send_email"),
        "metrics": mocker.patch("app.services.billing.record_event"),
    }
def test_full_billing_flow(billing_mocks):
    billing_mocks["stripe"].charges.create.return_value.id = "ch_x"

    process_payment(customer_id=1, amount=100)

    billing_mocks["stripe"].charges.create.assert_called_once()
    billing_mocks["email"].assert_called_once()
    billing_mocks["metrics"].assert_called_once()

Useful when a service has many dependencies and many tests need all of them mocked.

Pattern: parametrize with mocker

@pytest.mark.parametrize("channel,expected_method", [
    ("email", "send_email"),
    ("sms", "send_sms"),
    ("push", "send_push"),
])
def test_notification_dispatches_correct_method(mocker, channel, expected_method):
    mock = mocker.patch(f"app.services.notifications.{expected_method}")

    user = make_user(channel=channel)
    send_notification(user, "test message")

    mock.assert_called_once()

Combine parametrize + mocker to cover multiple scenarios.


Other useful mocker methods

mocker.resetall()

Resets the state of every mock in this test:

def test_with_reset(mocker):
    mock_a = mocker.patch("module.a")
    mock_b = mocker.patch("module.b")

    do_something()
    mock_a.assert_called()
    mock_b.assert_called()

    # Reset them all for a new phase of the test
    mocker.resetall()

    do_something_else()
    mock_a.assert_called_once()  # new count since the reset

mocker.stopall()

Stops all current patches (useful mid-test):

def test_with_stop(mocker):
    mocker.patch("module.func")
    do_with_mock()  # mocked function

    mocker.stopall()
    do_without_mock()  # the real function now

A rare use case, but useful when a test has "phases" with and without a mock.

mocker.create_autospec

Equivalent to unittest.mock.create_autospec (capsule 06):

from app.services import EmailService

def test_x(mocker):
    fake_email = mocker.create_autospec(EmailService)
    # fake_email respects the EmailService interface

Worked case: refactoring existing tests

You have this test suite with @patch decorators:

Before

from unittest.mock import patch


@patch("app.services.orders.record_event")
@patch("app.services.orders.send_email")
@patch("app.services.orders.calculate_total")
@patch("app.services.orders.OrderRepository")
def test_create_order(mock_repo, mock_calc, mock_send, mock_record):
    mock_repo.return_value.save.return_value = Order(id=1)
    mock_calc.return_value = Decimal("100")

    result = create_order(user_id=1, items=[...])

    assert result.id == 1
    mock_repo.return_value.save.assert_called_once()
    mock_calc.assert_called_once()
    mock_send.assert_called_once()
    mock_record.assert_called_once()


@patch("app.services.orders.record_event")
@patch("app.services.orders.send_email")
@patch("app.services.orders.calculate_total")
@patch("app.services.orders.OrderRepository")
def test_create_order_with_email_failure(mock_repo, mock_calc, mock_send, mock_record):
    mock_send.side_effect = Exception("SMTP error")

    with pytest.raises(Exception):
        create_order(user_id=1, items=[...])

    # Email failed, but does record_event still get called? Depends on the code

After, with pytest-mock

@pytest.fixture
def order_mocks(mocker):
    """Every OrderService dependency, mocked."""
    return {
        "repo": mocker.patch("app.services.orders.OrderRepository"),
        "calc": mocker.patch("app.services.orders.calculate_total"),
        "send": mocker.patch("app.services.orders.send_email"),
        "record": mocker.patch("app.services.orders.record_event"),
    }


def test_create_order(order_mocks):
    order_mocks["repo"].return_value.save.return_value = Order(id=1)
    order_mocks["calc"].return_value = Decimal("100")

    result = create_order(user_id=1, items=[...])

    assert result.id == 1
    order_mocks["repo"].return_value.save.assert_called_once()
    order_mocks["calc"].assert_called_once()
    order_mocks["send"].assert_called_once()
    order_mocks["record"].assert_called_once()


def test_create_order_with_email_failure(order_mocks):
    order_mocks["send"].side_effect = Exception("SMTP error")

    with pytest.raises(Exception):
        create_order(user_id=1, items=[...])

Visible improvements:

  • The order_mocks fixture is reused across multiple tests
  • No confusing decorator stacks
  • Clearly named variables ("repo", "send") instead of positional order
  • Shared setup (the mocks come pre-configured with autospec if you want)

Traps and common mistakes

Trap 1: using @patch when you already have mocker

# ❌ Mixing both
@patch("module.func")
def test_x(mock_func, mocker):
    other_mock = mocker.patch("module.other")
    ...

It works, but it's confusing. Use one or the other, not both. In projects with pytest-mock, the standard is mocker for everything.

Trap 2: forgetting autospec

mocker.patch defaults to a Mock without a spec. Same as unittest.mock.patch. Always consider autospec=True:

# ✅ Better
mock_repo = mocker.patch("app.services.orders.OrderRepository", autospec=True)

autospec makes the mock fail if you call nonexistent methods. We cover it in depth in capsule 06.

Trap 3: when spy is what you need (not patch)

Sometimes you want to verify that a function was called, but you want it to actually run:

# ❌ Over-patching: the real code doesn't run
def test_format_called(mocker):
    mocker.patch("app.helpers.format_summary", return_value="MOCKED")
    # now the rest of the flow's output is "MOCKED", not the real formatting
# ✅ Spy: the function runs for real and records calls
def test_format_called(mocker):
    spy = mocker.spy("app.helpers", "format_summary")
    do_thing()
    spy.assert_called_once_with(...)
    # the flow's output is the real formatting

Trap 4: spying on the code under test

# ❌ Anti-pattern: spying on the function you're testing
def test_calculate_total(mocker):
    spy = mocker.spy("app.calculator", "calculate_total")
    result = calculate_total(...)
    spy.assert_called_once()  # ← circular: of course it was called, you called it

spy is for functions that another part of the code calls. To verify that your call worked, just assert on the result.

Trap 5: fixture scope with mocker

@pytest.fixture(scope="session")  # ❌ session scope with mocker typically doesn't work
def expensive_mock(mocker):
    return mocker.patch(...)

mocker is function-scoped by design — cleanup happens at the end of each test. If you need a broader scope, ask yourself whether you really want a mock or whether you need a different strategy.

Trap 6: forgetting that mocker.patch returns the Mock

# ❌ Without assigning the result
def test_x(mocker):
    mocker.patch("module.func")  # ← you lose the reference to the Mock

    do_thing()
    # how do you verify the call? You don't have the Mock
# ✅ Assign the result
def test_x(mocker):
    mock_func = mocker.patch("module.func")

    do_thing()
    mock_func.assert_called_once()

Trap 7: verifying a mock that was never patched in

def test_x(mocker):
    mock = mocker.MagicMock()
    do_thing()
    mock.assert_called_once()  # ← this Mock isn't wired to anything

mocker.MagicMock() creates an orphan mock. For the code under test to use it, you have to patch it in or inject it.


Exercise: refactor existing tests

Take the tests from capsule 03 (the worked case of notify_user_about_charge) and refactor them to pytest-mock. Specifically:

  1. Replace the @patch decorators with mocker.patch inside the test
  2. Consider creating a shared fixture if the mocks repeat
  3. Add autospec=True where it makes sense (a preview of capsule 06)
  4. Add a test using mocker.spy to verify that a pure function was called (e.g. internal validators)
Suggested solution
# tests/conftest.py
import pytest


@pytest.fixture
def notification_mocks(mocker):
    """The notifications dependencies, mocked."""
    return {
        "email": mocker.patch("app.services.notifications.send_email"),
        "sms": mocker.patch("app.services.notifications.send_sms"),
        "record": mocker.patch("app.services.notifications.record_event"),
    }


# tests/unit/test_notifications.py
import pytest
from app.services.notifications import notify_user_about_charge


def test_notify_via_email(notification_mocks):
    user = make_user(channel="email")

    notify_user_about_charge(user, amount=5000, charge_id="ch_abc")

    notification_mocks["email"].assert_called_once_with(
        to="alice@example.com",
        subject="Payment received",
        body="We charged $50.00. Reference: ch_abc",
    )
    notification_mocks["sms"].assert_not_called()
    notification_mocks["record"].assert_called_once()


def test_notify_via_sms(notification_mocks):
    user = make_user(channel="sms", phone="+19998887777")

    notify_user_about_charge(user, amount=2500, charge_id="ch_xyz")

    notification_mocks["sms"].assert_called_once_with(
        to="+19998887777",
        message="Charge of $25.00 processed.",
    )
    notification_mocks["email"].assert_not_called()


def test_notify_unknown_channel_raises(notification_mocks):
    user = make_user(channel="webhook")

    with pytest.raises(ValueError, match="unknown channel: webhook"):
        notify_user_about_charge(user, amount=100, charge_id="ch_x")

    notification_mocks["email"].assert_not_called()
    notification_mocks["sms"].assert_not_called()
    notification_mocks["record"].assert_not_called()


def test_notify_does_not_record_when_send_fails(notification_mocks):
    user = make_user(channel="email")
    notification_mocks["email"].side_effect = Exception("SMTP error")

    with pytest.raises(Exception, match="SMTP error"):
        notify_user_about_charge(user, amount=100, charge_id="ch_x")

    notification_mocks["email"].assert_called_once()
    notification_mocks["record"].assert_not_called()


# A test using spy for an internal pure function
def test_notify_uses_format_helper(mocker, notification_mocks):
    """Verify the internal pure formatter is invoked with the right args."""
    spy = mocker.spy("app.services.notifications", "format_amount")

    user = make_user(channel="email")
    notify_user_about_charge(user, amount=5000, charge_id="ch_abc")

    spy.assert_called_once_with(5000)
    # format_amount ran the real code

Key changes:

  • The notification_mocks fixture removes the repeated setup of 3 mocks
  • Cleaner tests with no decorator stacks
  • spy to verify the internal formatter gets called (without mocking it)

Summary and next step

What you learned in this capsule:

  • pytest-mock wraps unittest.mock with automatic cleanup and natural pytest integration
  • mocker.patch replaces stacked decorators — declaration order = usage order
  • mocker.MagicMock / mocker.Mock / mocker.AsyncMock to create standalone mocks
  • mocker.spy to verify that a real function was called (it doesn't mock it)
  • Automatic cleanup eliminates the entire bug category of badly closed patches
  • Integration with pytest fixtures — fixtures that return mocks, scope-aware
  • Traps: mixing @patch + mocker, losing the reference to the mock, spying on the code under test

Checkpoint before moving on

Before continuing to the next capsule, you should be able to:

  • ✅ Replace @patch decorator stacks with mocker.patch in clear blocks
  • ✅ Decide when to use mocker.patch (replace) vs mocker.spy (verify)
  • ✅ Create a shared fixture that returns multiple mocks
  • ✅ Combine mocker.patch with parametrize to cover scenarios
  • ✅ Spot when an orphan MagicMock isn't wired to anything

Bridge to the next capsule

You have mocker.patch to replace dependencies. But so far you've seen a single behavior per mock: return_value=X. Reality is richer:

  • An external API can fail sometimes and you want to test the retry
  • A mock may have to return different values on each call (pagination, sequences)
  • A function may raise a specific exception that your code handles
  • A mock may have dynamic behavior depending on the args it receives

Capsule 05 covers side_effect — the mechanism that enables all of the above. It's the feature that separates mocks that "return a fixed thing" from mocks that "simulate real behavior".


Resources

  1. pytest-mock — official docs — the reference
  2. pytest-mock vs unittest.mock — the official comparison
  3. Real Python — pytest-mock guide — a practical tutorial
  4. Brian Okken — pytest-mock examples — advanced patterns
  5. How to use spy — the section on mocker.spy
  6. Migrating from unittest.mock — a migration guide
  7. pytest-mock GitHub — issues/source