Mocking - Patch Where the Name Is Used, Not Where It Is Defined
Reading time: ~30 minutes | Level: Intermediate → Engineering
Before reading further, predict whether this test passes or fails:
# payment.py
import requests
def charge_card(amount):
response = requests.post("https://api.payment.com/charge", json={"amount": amount})
return response.json()
# test_payment.py
from unittest.mock import patch
def test_charge():
with patch("requests.post") as mock_post:
mock_post.return_value.json.return_value = {"status": "ok"}
from payment import charge_card
result = charge_card(100)
assert result == {"status": "ok"}
Show Answer
This test may fail, and when it fails, it is silent in the worst way - it depends on import order.
When Python runs import requests inside payment.py, it binds the name requests in payment's own namespace. The binding looks like this:
payment.requests → <module 'requests'>
When you call patch("requests.post"), you are patching post on the requests module object directly. If payment was imported before the patch was applied (e.g., at the top of the test file), the patch applies to the same object payment.requests points to - so it may appear to work. But this is fragile and depends on module load order.
The correct, unambiguous form is always:
with patch("payment.requests.post") as mock_post:
This patches post on the requests object that lives in payment's namespace - which is exactly what charge_card calls. The rule: patch where the name is used, not where it is defined.
The even more common variant: if payment.py had written from requests import post, then payment.post is the correct target - patching requests.post would have zero effect because payment.post is a completely separate binding.
Now consider: this is one of the most common mistakes in test suites at every experience level. Engineers spend hours debugging a mock "that should work" while the real HTTP call fires in CI, hammers a production API, or causes a flaky test that only fails on clean environments where import order differs. Understanding how Python's name binding interacts with patching is the difference between mocks you can trust and mocks that lie to you.
What You Will Learn
- The golden rule of patching: where the name is used, not defined
- How Python import binding makes the "wrong target" bug possible
Mock:return_value,side_effect,called,call_count,call_args,call_args_listMagicMockvsMock: when magic methods are auto-configured and when they are notpatchas a decorator, context manager, andpatch.objectpatch.dictfor environment variables andsys.modulesspec=andautospec=True: catching attribute typos at test timeside_effect: exceptions, different values per call, callable side effects- The typo danger:
assert_called_onceis not the same asassert_called_once_with sentinel,create_autospec, andAsyncMockpytest-mockand themockerfixture- When NOT to mock: boundaries only, never pure functions
Prerequisites
- Familiarity with
pytestbasics (writing and running tests) - Python's import system: how
import Xbinds a name in the current module's namespace - Basic understanding of decorators and context managers
Part 1 - The Golden Rule: Patch Where the Name Is Used
How Import Binding Creates the Problem
When payment.py runs import requests, Python:
- Looks up
requestsinsys.modules(loads it if not already loaded) - Binds the name
requestsinpayment.py's global namespace
The safer, canonical form is always patch("payment.requests.post"). It makes the target explicit: you are patching the post attribute on the requests object that lives in payment's namespace.
The Rule Across All Import Styles
# ---- Case 1: module import ----
# email_sender.py: import smtplib
with patch("email_sender.smtplib.SMTP") as mock_smtp: # correct
# ---- Case 2: from import ----
# email_sender.py: from smtplib import SMTP
with patch("email_sender.SMTP") as mock_smtp: # correct
# patching smtplib.SMTP here would NOT work - email_sender.SMTP
# is a separate binding created at import time
# ---- Case 3: aliased import ----
# email_sender.py: import smtplib as smtp_lib
with patch("email_sender.smtp_lib.SMTP") as mock_smtp: # correct
:::tip The Golden Rule
Always ask: "which namespace does the code under test look up the name in?" Patch that namespace. If payment.py does import requests, patch payment.requests.post. If it does from requests import post, patch payment.post.
:::
Part 2 - Mock and MagicMock
The Mock Class
unittest.mock.Mock is a flexible object that records every interaction with it and returns configurable values:
from unittest.mock import Mock
m = Mock()
# Every attribute access returns a new Mock automatically
print(type(m.anything)) # <class 'unittest.mock.Mock'>
print(type(m.anything.nested)) # <class 'unittest.mock.Mock'>
# Every call returns mock.return_value (another Mock by default)
result = m(1, 2, key="value")
print(result) # <Mock name='mock()' id='...'>
# Configure return_value
m.return_value = 42
print(m()) # 42
# Introspect calls
m("hello", "world")
print(m.called) # True
print(m.call_count) # 2 (called twice total since return_value was set)
print(m.call_args) # call('hello', 'world') - most recent call
print(m.call_args_list) # [call(1, 2, key='value'), call('hello', 'world')]
Chaining return_value for Deep Method Calls
Real APIs often chain calls. return_value composes naturally:
from unittest.mock import Mock, patch
# Simulating: response = requests.post(url); data = response.json()
mock_post = Mock()
mock_post.return_value.json.return_value = {"status": "charged", "id": "txn_123"}
# In your test:
with patch("payment.requests.post", mock_post):
result = charge_card(100)
assert result == {"status": "charged", "id": "txn_123"}
mock_post.assert_called_once_with(
"https://api.payment.com/charge",
json={"amount": 100}
)
MagicMock vs Mock
MagicMock is a subclass of Mock that pre-configures Python's magic (dunder) methods. Mock does not configure them by default:
from unittest.mock import Mock, MagicMock
m = Mock()
mm = MagicMock()
# __len__ - Mock raises TypeError; MagicMock returns 0
len(mm) # 0 - works
len(m) # TypeError: object of type 'Mock' has no len()
# __iter__ - Mock raises TypeError; MagicMock returns empty iterator
list(mm) # [] - works
list(m) # TypeError
# __enter__ / __exit__ - essential for context managers
with mm as ctx: # MagicMock works as a context manager automatically
pass
with m as ctx: # TypeError: 'Mock' object does not support context manager protocol
# __bool__ - both are truthy by default; MagicMock's is configurable
mm.__bool__.return_value = False
bool(mm) # False
Use MagicMock (the default in most contexts) when your code under test uses the mocked object as a context manager, iterates over it, calls len() on it, or uses any dunder method.
:::note MagicMock Supports Dunder Methods Automatically
MagicMock auto-configures __len__, __iter__, __enter__, __exit__, __contains__, __getitem__, and all other magic methods. Mock does not. Use MagicMock whenever the mocked object participates in Python's protocols. patch() returns a MagicMock by default.
:::
Mock Class Hierarchy
Part 3 - Patch: Three Forms
Context Manager (Most Explicit)
from unittest.mock import patch, Mock
def test_charge_card_success():
with patch("payment.requests.post") as mock_post:
mock_post.return_value.json.return_value = {"status": "ok"}
mock_post.return_value.status_code = 200
result = charge_card(50)
# Assertions happen AFTER the with block - mock is already restored
assert result == {"status": "ok"}
mock_post.assert_called_once_with(
"https://api.payment.com/charge",
json={"amount": 50}
)
Decorator (Most Common in Test Classes)
from unittest.mock import patch
@patch("payment.requests.post")
def test_charge_card_as_decorator(mock_post):
# mock_post is injected as the last argument (or first, after self)
mock_post.return_value.json.return_value = {"status": "ok"}
result = charge_card(75)
assert result == {"status": "ok"}
# Stacking decorators - bottom decorator maps to first injected arg
@patch("payment.requests.post")
@patch("payment.uuid.uuid4")
def test_with_multiple_patches(mock_uuid, mock_post):
# Reading order: innermost (bottom) decorator → first arg
mock_uuid.return_value = "fixed-uuid-1234"
mock_post.return_value.json.return_value = {"status": "ok"}
result = charge_card(100)
assert result == {"status": "ok"}
patch.object - Patch a Method on an Instance or Class
from unittest.mock import patch, Mock
class DatabaseClient:
def query(self, sql):
# real DB call
...
client = DatabaseClient()
# patch.object targets an attribute on a specific object or class
with patch.object(client, "query", return_value=[{"id": 1}]) as mock_query:
result = client.query("SELECT * FROM users")
assert result == [{"id": 1}]
mock_query.assert_called_once_with("SELECT * FROM users")
# On the class itself - affects all instances created during the patch
with patch.object(DatabaseClient, "query", return_value=[]) as mock_query:
new_client = DatabaseClient()
assert new_client.query("SELECT 1") == []
patch.dict - Environment Variables and sys.modules
import os
from unittest.mock import patch
def get_api_key():
return os.environ["API_KEY"]
# patch.dict temporarily modifies os.environ
with patch.dict(os.environ, {"API_KEY": "test-key-abc123"}):
assert get_api_key() == "test-key-abc123"
# Environment is restored after the with block
assert "API_KEY" not in os.environ # if it wasn't there before
# Block an import to test behaviour when an optional dependency is missing
import sys
with patch.dict(sys.modules, {"numpy": None}):
# Inside here, importing numpy raises ImportError
# Useful for testing graceful degradation paths
pass
Part 4 - side_effect: Exceptions, Sequences, and Callables
side_effect is more powerful than return_value. It defines what happens when the mock is called and overrides return_value completely:
from unittest.mock import Mock, patch
import requests
# 1. Raise an exception on every call
mock_post = Mock()
mock_post.side_effect = requests.exceptions.ConnectionError("Network down")
with patch("payment.requests.post", mock_post):
try:
charge_card(100)
except requests.exceptions.ConnectionError:
pass # expected
# 2. Return different values on successive calls (iterable)
success = Mock(**{"json.return_value": {"status": "ok"}})
mock_post.side_effect = [
requests.exceptions.Timeout(), # first call
Mock(**{"json.return_value": {"status": "pending"}}), # second call
success, # third call
]
# Fourth call raises StopIteration (exhausted)
# 3. Use a callable - full control over logic per call
def fake_post(url, **kwargs):
if kwargs.get("json", {}).get("amount", 0) > 10_000:
raise ValueError("Amount exceeds limit")
return Mock(**{"json.return_value": {"status": "ok"}})
mock_post.side_effect = fake_post
# 4. Clear side_effect - reverts to return_value behaviour
mock_post.side_effect = None
mock_post.return_value = Mock(**{"json.return_value": {"status": "ok"}})
:::warning side_effect Takes Priority Over return_value
When both side_effect and return_value are set, side_effect wins. If side_effect is a callable that returns DEFAULT, then return_value is used as the result. If side_effect raises, return_value is never consulted. Clear it by setting mock.side_effect = None.
:::
Part 5 - spec and autospec: Catching Attribute Errors at Test Time
The Problem Without spec
from unittest.mock import Mock
# No spec: any attribute access succeeds silently
m = Mock()
m.typo_method() # returns a Mock - no error
m.completely_made_up_attr # returns a Mock - no error
# This means your mocks never catch typos or API changes
# Tests pass even when the real object doesn't have that method
spec= - Limits Attributes to the Real Interface
from unittest.mock import Mock
import requests
# spec restricts the mock to the interface of the spec object/class
mock_response = Mock(spec=requests.Response)
mock_response.json() # OK - Response has .json()
mock_response.status_code # OK - Response has .status_code
mock_response.nonexistent_attr # AttributeError - not on Response
# Useful for ensuring your test double matches the real API
mock_response.raise_for_status() # OK - Response has .raise_for_status()
autospec=True - Enforces Signatures Too
from unittest.mock import patch, create_autospec
from payment import charge_card
# autospec=True creates a spec AND validates call signatures
with patch("payment.requests.post", autospec=True) as mock_post:
mock_post.return_value.json.return_value = {"status": "ok"}
charge_card(100)
# Calling with wrong signature raises TypeError immediately in the test
# create_autospec for standalone use
mock_charge = create_autospec(charge_card)
mock_charge(100) # OK - matches signature
mock_charge() # TypeError: missing required argument 'amount'
mock_charge(1, 2) # TypeError: too many positional arguments
:::warning autospec=True Has a Performance Cost
autospec=True inspects the full signature of the target using inspect, wraps every method, and validates every call at test time. For critical system boundaries (external HTTP APIs, databases, message queues) this is the right tradeoff - it catches interface drift immediately. For internal helpers called thousands of times in a test suite, the overhead adds up. Use autospec=True at system boundaries; use spec= for lightweight shape checking.
:::
Part 6 - Assert Methods (and the Dangerous Typo)
Correct Assert Methods
from unittest.mock import Mock
m = Mock()
m(1, 2, key="value")
m(3, 4)
# assert_called_once_with - exactly one call AND exact args
m2 = Mock()
m2("hello")
m2.assert_called_once_with("hello") # passes
m2.assert_called_once_with("wrong") # AssertionError
# assert_called_with - most recent call matches (not the only call)
m.assert_called_with(3, 4) # passes - last call was (3, 4)
m.assert_called_with(1, 2, key="value") # AssertionError - not the last call
# assert_any_call - at least one call matched these args
m.assert_any_call(1, 2, key="value") # passes - first call matches
m.assert_any_call(99) # AssertionError - no call with arg 99
# assert_not_called - never called at all
m3 = Mock()
m3.assert_not_called() # passes
m3()
m3.assert_not_called() # AssertionError
# assert_called - called at least once, no argument checking
m.assert_called() # passes
:::danger The Typo That Silently Passes
assert_called_once is a real method. It verifies the mock was called exactly once - but it does NOT check arguments. This is almost never what you want:
m = Mock()
m("real_api_key", extra_arg="dangerous_value")
# INTENDED - assert called once with exactly these args:
m.assert_called_once_with("real_api_key")
# → AssertionError: unexpected keyword argument extra_arg='dangerous_value'
# TYPO - silently passes, ignores ALL arguments:
m.assert_called_once()
# → PASSES - the extra dangerous argument is never caught
# WORSE TYPO - misspelled method name returns a new Mock, always truthy:
m.assert_called_onc_with("real_api_key") # typo: 'onc' not 'once'
# → Returns a Mock() - evaluates as True - test PASSES with no assertion made
Always use assert_called_once_with() (with the _with suffix) when you care about arguments. Use autospec=True or pytest-mock to catch misspelled assert method names at test time.
:::
Part 7 - sentinel, AsyncMock, and pytest-mock
sentinel: Unique Placeholder Values
from unittest.mock import sentinel, Mock
# sentinel creates unique singleton objects for testing identity
# Use when you care that a specific object was passed, not its value
m = Mock()
m.process(sentinel.REQUEST_OBJECT)
m.assert_called_once_with(sentinel.REQUEST_OBJECT) # passes
m.assert_called_once_with(sentinel.DIFFERENT) # AssertionError
# sentinel objects have meaningful repr and identity:
print(sentinel.REQUEST_OBJECT) # sentinel.REQUEST_OBJECT
print(sentinel.REQUEST_OBJECT is sentinel.REQUEST_OBJECT) # True - same object
print(sentinel.REQUEST_OBJECT is sentinel.OTHER) # False - distinct
AsyncMock for Async Functions (Python 3.8+)
import asyncio
from unittest.mock import AsyncMock, patch
# The code under test
async def fetch_user(session, user_id):
response = await session.get(f"/users/{user_id}")
return await response.json()
# Mock() does not work for async functions - it returns a non-awaitable
# AsyncMock() returns an awaitable that resolves to return_value
async def test_fetch_user():
mock_session = AsyncMock()
mock_session.get.return_value.json.return_value = {"id": 1, "name": "Alice"}
result = await fetch_user(mock_session, 1)
assert result == {"id": 1, "name": "Alice"}
mock_session.get.assert_awaited_once_with("/users/1")
asyncio.run(test_fetch_user())
# patch() uses AsyncMock automatically when the target is a coroutine function
async def send_email(address, subject):
... # internally awaits an async SMTP client
with patch("mailer.send_email", new_callable=AsyncMock) as mock_send:
mock_send.return_value = True
# ... test code that awaits send_email
pytest-mock - The mocker Fixture
pytest-mock wraps unittest.mock behind a mocker fixture that handles cleanup automatically and provides a cleaner API:
# Install: pip install pytest-mock
# test_payment.py
def test_charge_card(mocker):
# mocker.patch - no context manager or decorator overhead needed
# cleanup (unpatch) happens automatically after each test
mock_post = mocker.patch("payment.requests.post")
mock_post.return_value.json.return_value = {"status": "ok"}
mock_post.return_value.status_code = 200
result = charge_card(100)
assert result == {"status": "ok"}
mock_post.assert_called_once_with(
"https://api.payment.com/charge",
json={"amount": 100},
)
# mocker.patch.object - same as patch.object but auto-cleaned
def test_database_query(mocker, db_client):
mocker.patch.object(db_client, "execute", return_value=[{"id": 1}])
rows = db_client.execute("SELECT * FROM users")
assert rows == [{"id": 1}]
# mocker.spy - wraps the real function to record calls without replacing it
def test_with_spy(mocker):
spy = mocker.spy(payment, "charge_card")
charge_card(200)
spy.assert_called_once_with(200)
# The real charge_card also ran - spy records but does not replace
:::tip Use pytest-mock's mocker in New Projects
The mocker fixture auto-cleans patches after each test (no forgotten patcher.stop() calls), provides mocker.spy() for non-replacing observation, and produces cleaner test signatures than stacking @patch decorators. For greenfield projects, prefer mocker over raw patch.
:::
Part 8 - When NOT to Mock
Mock Only at System Boundaries
The most important mocking rule is knowing when not to mock:
# ---- WRONG: mocking your own pure functions ----
# This test "passes" but verifies nothing about real behaviour
def add(a, b):
return a + b
def test_calculator(mocker):
mocker.patch("calculator.add", return_value=5) # tells you nothing
result = calculate_total([1, 2]) # tests only the mock
assert result == 5
# ---- RIGHT: mock only system boundaries ----
# System boundaries: HTTP, database, filesystem, time, external processes, RNG
# Mock HTTP
with patch("myapp.requests.post") as mock_post:
mock_post.return_value.json.return_value = {"id": 42}
result = create_user({"name": "Alice"})
# Mock time (datetime.now() changes every call - non-deterministic without mock)
with patch("myapp.datetime") as mock_dt:
mock_dt.now.return_value = datetime(2025, 1, 1, 12, 0, 0)
result = generate_timestamp_filename()
assert result == "report_2025-01-01_120000.csv"
# Mock filesystem (prevent actual file writes in unit tests)
from unittest.mock import mock_open
with patch("builtins.open", mock_open(read_data="api_key=abc123")):
result = load_config("config.ini")
assert result["api_key"] == "abc123"
The rule: mock at the boundary between your code and the external world. Never mock your own pure functions - those should be tested with real inputs and real outputs.
Part 9 - A Production-Correct Test Suite
# payment.py - module under test
import requests
class PaymentError(Exception):
pass
def charge_card(amount: float, currency: str = "usd") -> dict:
if amount <= 0:
raise ValueError(f"Amount must be positive, got {amount}")
try:
response = requests.post(
"https://api.payment.com/charge",
json={"amount": amount, "currency": currency},
timeout=10,
)
response.raise_for_status()
return response.json()
except requests.exceptions.Timeout:
raise PaymentError("Payment gateway timed out")
except requests.exceptions.HTTPError as e:
raise PaymentError(f"Payment failed: {e}")
# test_payment.py - production-quality tests
import pytest
from unittest.mock import Mock
from payment import charge_card, PaymentError
class TestChargeCard:
def test_successful_charge(self, mocker):
"""Happy path: API returns success."""
mock_post = mocker.patch("payment.requests.post")
mock_post.return_value.json.return_value = {
"status": "charged",
"transaction_id": "txn_abc123",
}
mock_post.return_value.raise_for_status = Mock() # no-op
result = charge_card(100.00)
assert result["status"] == "charged"
mock_post.assert_called_once_with(
"https://api.payment.com/charge",
json={"amount": 100.00, "currency": "usd"},
timeout=10,
)
def test_timeout_raises_payment_error(self, mocker):
"""Gateway timeout converts to PaymentError."""
mock_post = mocker.patch("payment.requests.post")
mock_post.side_effect = requests.exceptions.Timeout()
with pytest.raises(PaymentError, match="timed out"):
charge_card(100.00)
def test_http_error_raises_payment_error(self, mocker):
"""4xx/5xx from gateway converts to PaymentError."""
mock_post = mocker.patch("payment.requests.post")
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
"402 Client Error"
)
mock_post.return_value = mock_response
with pytest.raises(PaymentError, match="Payment failed"):
charge_card(100.00)
def test_negative_amount_raises_value_error(self):
"""Pure validation - no mock needed, no HTTP boundary."""
with pytest.raises(ValueError, match="must be positive"):
charge_card(-50)
def test_retry_on_first_failure(self, mocker):
"""side_effect sequence: first call fails, second succeeds."""
mock_post = mocker.patch("payment.requests.post")
success = Mock()
success.json.return_value = {"status": "ok"}
success.raise_for_status = Mock()
mock_post.side_effect = [
requests.exceptions.Timeout(), # first attempt
success, # second attempt
]
# assumes charge_card has retry logic
result = charge_card(100.00)
assert mock_post.call_count == 2
Graded Practice Challenges
Level 1 - Predict and Identify
Question 1: What does this print, and why?
from unittest.mock import Mock
m = Mock()
m.do_thing(1, 2, 3)
m.do_thing(4, 5, 6)
print(m.do_thing.call_count)
print(m.do_thing.call_args)
Show Answer
2
call(4, 5, 6)
call_count is 2 because do_thing was called twice. call_args is the most recent call's arguments - call(4, 5, 6). To see all calls, use m.do_thing.call_args_list which returns [call(1, 2, 3), call(4, 5, 6)].
Question 2: Which assertion silently passes even when called with wrong arguments?
from unittest.mock import Mock
m = Mock()
m("correct_token", extra="leaked_secret")
# Assertion A:
m.assert_called_once_with("correct_token")
# Assertion B:
m.assert_called_once()
Show Answer
Assertion B passes silently. assert_called_once() only verifies that the mock was called exactly once. It does not examine arguments at all. extra="leaked_secret" goes completely undetected.
Assertion A raises AssertionError because the actual call was m("correct_token", extra="leaked_secret") but the assertion expected m("correct_token") only. The extra keyword argument causes a mismatch.
Always use assert_called_once_with() when arguments matter.
Question 3: This code patches at the wrong target. What is the bug and what is the correct patch path?
# validator.py
import re
def is_valid_email(address):
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", address))
# test_validator.py
from unittest.mock import patch
def test_is_valid_email():
with patch("re.match") as mock_match:
mock_match.return_value = True
from validator import is_valid_email
Show Answer
Two issues:
-
Wrong patch target. The correct target is
validator.re.match- becausevalidator.pybindsrein its own namespace and callsre.matchthrough that binding. -
Unnecessary mock.
is_valid_emailis a pure function with no system boundary. This should be tested with real inputs:
def test_is_valid_email():
assert is_valid_email("notanemail") is False
assert is_valid_email("@nodomain.com") is False
Mocking re.match here tells you nothing about whether the regex is correct - which is the entire point of the function.
Question 4: What is the difference between Mock and MagicMock here?
from unittest.mock import Mock, MagicMock
m = Mock()
mm = MagicMock()
result_a = len(m)
result_b = len(mm)
result_c = list(m)
result_d = list(mm)
Show Answer
len(m)→TypeError:Mockdoes not configure__len__len(mm)→0:MagicMockpre-configures__len__to return0list(m)→TypeError:Mockdoes not configure__iter__list(mm)→[]:MagicMockpre-configures__iter__to return an empty iterator
Use MagicMock when the code under test uses the mocked object in protocols (iteration, context manager, length check). Use Mock for simple callables with no protocol behaviour.
Level 2 - Debug and Fix
Find and fix all issues in this test file:
from unittest.mock import patch, Mock
# Bug 1: wrong patch target
# database.py does: import sqlite3
def test_database_query():
with patch("sqlite3.connect") as mock_connect:
from database import get_users
users = get_users()
assert users == []
# Bug 2: typo on assert method
def test_send_notification(mocker):
mock_send = mocker.patch("notifier.smtp_client.send")
# Bug 3: mocking a pure function unnecessarily
def test_calculate_discount(mocker):
mocker.patch("pricing.calculate_discount", return_value=10.0)
result = calculate_discount(100.0, rate=0.10)
assert result == 10.0
# Bug 4: Mock used where MagicMock is needed
def test_file_processor():
mock_file = Mock()
with mock_file as f: # context manager protocol needed
f.read.return_value = "file content"
process_file(mock_file)
Show Solution
Bug 1 - Wrong patch target:
def test_database_query():
# database.py binds sqlite3 in its namespace - patch there
with patch("database.sqlite3.connect") as mock_connect:
mock_connect.return_value.__enter__ = Mock(return_value=Mock())
mock_connect.return_value.__exit__ = Mock(return_value=False)
from database import get_users
users = get_users()
assert users == []
Bug 2 - assert_called_once does not check arguments:
def test_send_notification(mocker):
mock_send = mocker.patch("notifier.smtp_client.send")
# Fix: assert_called_once_with (not assert_called_once)
Bug 3 - Mocking a pure function tells you nothing:
def test_calculate_discount():
# No mock needed - test the real function with real inputs
result = calculate_discount(100.0, rate=0.10)
assert result == 10.0
Bug 4 - Mock does not support context manager protocol:
from unittest.mock import MagicMock
def test_file_processor():
# MagicMock auto-configures __enter__ and __exit__
mock_file = MagicMock()
mock_file.__enter__.return_value.read.return_value = "file content"
with mock_file as f: # works with MagicMock
pass
process_file(mock_file)
Level 3 - Design Challenge
Design a MockHTTPClient test helper class that:
- Acts as a drop-in replacement for
requests.Session - Accepts a
responsesdict mapping(method, url)tuples to response dicts - Raises
requests.exceptions.Timeoutwhen the URL contains"slow.api.com" - Records all calls with method, URL, and body
- Has an
assert_requested(method, url, body=None)helper that produces a readable failure message - Works as a context manager
Show Reference Solution
from unittest.mock import Mock, patch
import requests
class MockHTTPClient:
"""
Test double for requests.Session.
Usage:
client = MockHTTPClient({
("POST", "https://api.example.com/charge"): {"status": "ok"},
("GET", "https://api.example.com/user/1"): {"id": 1},
})
with patch("myapp.requests.post", side_effect=client.post):
result = myapp.charge_card(100)
client.assert_requested("POST", "https://api.example.com/charge",
body={"amount": 100})
"""
def __init__(self, responses: dict = None):
self._responses = responses or {}
self._calls: list[dict] = []
def _make_response(self, data: dict) -> Mock:
response = Mock()
response.json.return_value = data
response.status_code = 200
response.raise_for_status = Mock()
return response
def _call(self, method: str, url: str, **kwargs) -> Mock:
if "slow.api.com" in url:
raise requests.exceptions.Timeout(f"Request to {url} timed out")
self._calls.append({
"method": method.upper(),
"url": url,
"body": kwargs.get("json") or kwargs.get("data"),
})
key = (method.upper(), url)
if key not in self._responses:
raise requests.exceptions.HTTPError(
f"404: no mock registered for {method.upper()} {url}"
)
return self._make_response(self._responses[key])
def get(self, url, **kwargs):
return self._call("GET", url, **kwargs)
def post(self, url, **kwargs):
return self._call("POST", url, **kwargs)
def put(self, url, **kwargs):
return self._call("PUT", url, **kwargs)
def delete(self, url, **kwargs):
return self._call("DELETE", url, **kwargs)
def assert_requested(self, method: str, url: str, body=None):
matching = [
c for c in self._calls
if c["method"] == method.upper() and c["url"] == url
]
if not matching:
made = [(c["method"], c["url"]) for c in self._calls]
raise AssertionError(
f"Expected {method.upper()} {url} but it was never called.\n"
f"Calls made: {made}"
)
if body is not None:
bodies = [c["body"] for c in matching]
if body not in bodies:
raise AssertionError(
f"Expected {method.upper()} {url} with body {body!r}\n"
f"Actual bodies: {bodies}"
)
def __enter__(self):
return self
def __exit__(self, *exc):
return False
# Usage in a test
def test_charge_card_with_helper():
client = MockHTTPClient({
("POST", "https://api.payment.com/charge"): {
"status": "charged",
"id": "txn_1",
},
})
with patch("payment.requests.post", side_effect=client.post):
result = charge_card(100)
assert result["status"] == "charged"
client.assert_requested(
"POST",
"https://api.payment.com/charge",
body={"amount": 100, "currency": "usd"},
)
Design decisions:
_callis the single dispatch point - all HTTP methods go through it, making timeout injection and call recording uniformassert_requestedproduces a readable failure message that lists what was actually called, making debug fast- Unregistered URLs raise
HTTPErrorrather than silently returning an empty Mock - missing mock registrations surface immediately - The class does not inherit from
Mock- it is a purpose-built test double with an explicit, documented interface
Key Takeaways
- Patch where the name is used, not where it is defined. If
payment.pydoesimport requests, patchpayment.requests.post. If it doesfrom requests import post, patchpayment.post Mockrecords all interactions:called,call_count,call_args,call_args_list,return_valueMagicMockauto-configures dunder methods (__len__,__iter__,__enter__,__exit__); use it when the mock participates in Python protocols;patch()returnsMagicMockby defaultpatch()as context manager gives the clearest cleanup semantics; as a decorator it injects the mock as an argument;patch.objectpatches a named attribute on a specific object or classpatch.dictpatches dictionaries (includingos.environandsys.modules) and restores the original state on exitside_effectoverridesreturn_value: use it to raise exceptions, return different values per call (via an iterable), or run a callable with full logicautospec=Truevalidates both attribute existence and call signatures at test time - use it at critical system boundaries; it has a performance costassert_called_once()ignores arguments - always useassert_called_once_with()when you care about what was passed- Misspelled assert method names (e.g.,
assert_called_onc_with) return a newMockthat is always truthy - the test silently passes with no assertion made AsyncMockis required forasync deffunctions;Mock()returns a non-awaitable and causesTypeErrorin async contextspytest-mock'smockerfixture auto-cleans patches after each test and providesmocker.spy()for non-replacing observation- Mock only at system boundaries: HTTP, database, filesystem, time, random. Never mock your own pure functions - test them with real inputs
What's Next
Lesson 04 covers Test-Driven Development - writing the failing test first, then the minimal implementation to make it pass. You will build a complete BankAccount class from scratch using TDD, see how writing tests first surfaces design problems before they reach production, and learn when TDD works best and when it creates friction.
