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(), rememberstop()in afinally - Verbose imports:
from unittest.mock import patch, MagicMockin 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: @patch → mocker.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 withorder)
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_mocksfixture 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:
- Replace the
@patchdecorators withmocker.patchinside the test - Consider creating a shared fixture if the mocks repeat
- Add
autospec=Truewhere it makes sense (a preview of capsule 06) - Add a test using
mocker.spyto 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_mocksfixture 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-mockwrapsunittest.mockwith automatic cleanup and natural pytest integrationmocker.patchreplaces stacked decorators — declaration order = usage ordermocker.MagicMock/mocker.Mock/mocker.AsyncMockto create standalone mocksmocker.spyto 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
@patchdecorator stacks withmocker.patchin clear blocks - ✅ Decide when to use
mocker.patch(replace) vsmocker.spy(verify) - ✅ Create a shared fixture that returns multiple mocks
- ✅ Combine
mocker.patchwithparametrizeto cover scenarios - ✅ Spot when an orphan
MagicMockisn'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
- pytest-mock — official docs — the reference
- pytest-mock vs unittest.mock — the official comparison
- Real Python — pytest-mock guide — a practical tutorial
- Brian Okken — pytest-mock examples — advanced patterns
- How to use spy — the section on mocker.spy
- Migrating from unittest.mock — a migration guide
- pytest-mock GitHub — issues/source