Mocking and Test Doubles (unittest.mock)
Mocking and Test Doubles (unittest.mock)
After reading this chapter, you will master Python’s testing and isolation toolkit using the standard library unittest.mock module, replace unreliable external boundaries (databases, HTTP APIs, file systems, clocks) with robust test doubles, intercept functions and classes using @patch and patch.object, enforce API contracts and prevent mock interface drift using autospec=True, simulate multi-step network failures with side_effect, and test asynchronous coroutines using AsyncMock.
Mental model
Unit tests should be deterministic, isolated, and fast. However, production code frequently depends on non-deterministic boundaries: remote webhooks, cloud databases, external clock times, or disk storage.
The unittest.mock library creates test doubles—objects that impersonate real components, record how they were called, and return pre-programmed responses:
┌─────────────────────────────────────────────────────────────┐
│ Production System Flow │
│ Client Code ──▶ [ Real Database / Stripe API / SMTP ] │
│ (Slow, unreliable, requires credentials) │
└─────────────────────────────────────────────────────────────┘
│
Test Substitution
▼
┌─────────────────────────────────────────────────────────────┐
│ Isolated Unit Test Flow │
│ Client Code ──▶ [ unittest.mock.Mock ] │
│ ├─ return_value: {'status': 'PAID'} │
│ ├─ side_effect: ConnectionError() │
│ └─ records: call_count, call_args │
└─────────────────────────────────────────────────────────────┘
The Golden Rule of Patching: “Where to Patch”
The single most common bug when using unittest.mock.patch is patching the module where a class or function was defined, rather than where it was imported and looked up:
WRONG:
patch('urllib.request.urlopen') # Does NOT affect service.py!
service.py imported: from urllib.request import urlopen (local reference)
RIGHT:
patch('my_app.service.urlopen') # Patches the reference inside service.py!
Minimal example
Save as mock_overview.py:
# mock_overview.py
from unittest.mock import Mock, patch
def send_welcome_email(email_client, user_email: str) -> bool:
"""Sends a welcome message and returns True on success."""
response = email_client.send(
to=user_email,
subject="Welcome aboard!",
body="Thank you for joining our platform.",
)
return response.get("delivered", False)
def main() -> None:
# 1. Instantiate a mock client
mock_client = Mock()
# Configure return value
mock_client.send.return_value = {"delivered": True, "message_id": "msg_9012"}
# 2. Execute target function under test
result = send_welcome_email(mock_client, "alice@example.com")
print(f"Function result: {result}")
# 3. Assert mock interactions
print(f"send() was called: {mock_client.send.called}")
print(f"send() call count: {mock_client.send.call_count}")
# Verify exact keyword arguments passed
mock_client.send.assert_called_once_with(
to="alice@example.com",
subject="Welcome aboard!",
body="Thank you for joining our platform.",
)
print("All mock assertions succeeded!")
if __name__ == "__main__":
main()Run via uv run python mock_overview.py:
Function result: True
send() was called: True
send() call count: 1
All mock assertions succeeded!
Worked examples
Case 1: Simulating Flaky Networks with side_effect Retries
A mock’s side_effect attribute can accept an exception class or instance (to raise errors) or an iterable (to yield different return values on consecutive calls). This enables testing exponential backoff and retry loops:
# retry_client_test.py
import time
from unittest.mock import Mock
class TransientNetworkError(Exception):
pass
def fetch_telemetry_with_retry(api_client, endpoint: str, max_retries: int = 3) -> dict:
"""Fetches data from an API client, retrying up to max_retries on transient errors."""
for attempt in range(1, max_retries + 1):
try:
return api_client.get(endpoint)
except TransientNetworkError:
if attempt == max_retries:
raise
time.sleep(0.01 * attempt)
return {}
def test_retry_succeeds_on_third_attempt() -> None:
mock_api = Mock()
# Sequence: Fail with error twice, then succeed on third attempt
mock_api.get.side_effect = [
TransientNetworkError("Gateway Timeout (504)"),
TransientNetworkError("Connection Reset"),
{"nodes_online": 42, "status": "HEALTHY"},
]
result = fetch_telemetry_with_retry(mock_api, "/api/v1/cluster/status")
print(f"Payload received: {result}")
print(f"Total API attempts made: {mock_api.get.call_count}")
assert mock_api.get.call_count == 3
print("Test passed: Retry loop successfully recovered on attempt 3!")
if __name__ == "__main__":
test_retry_succeeds_on_third_attempt()Run:
uv run python retry_client_test.pyOutput:
Payload received: {'nodes_online': 42, 'status': 'HEALTHY'}
Total API attempts made: 3
Test passed: Retry loop successfully recovered on attempt 3!
Case 2: Preventing Mock Drift with autospec=True
Standard Mock and MagicMock instances dynamically create any attribute or method accessed on them. If production code refactors a method name or changes parameter signatures, tests using vanilla mocks continue to pass silently. autospec=True binds the mock directly to the real class specification:
# autospec_drift_prevention.py
from unittest.mock import Mock, create_autospec
class BillingGateway:
def charge_customer(self, customer_id: str, amount_cents: int, currency: str = "USD") -> str:
"""Charges a registered customer's saved payment method."""
return f"tx_success_{customer_id}_{amount_cents}"
def test_vanilla_mock_drift() -> None:
# Vanilla Mock permits non-existent methods and wrong signatures!
vanilla_mock = Mock()
vanilla_mock.non_existent_method("foo")
vanilla_mock.charge_customer("cust_01") # Missing required 'amount_cents'!
print("Vanilla mock silently succeeded despite invalid method names and missing arguments.")
def test_autospec_drift_detection() -> None:
# create_autospec introspects BillingGateway methods and signatures
safe_mock = create_autospec(BillingGateway, instance=True)
# 1. Calling non-existent method raises AttributeError immediately
try:
safe_mock.non_existent_method("foo")
except AttributeError as err:
print(f"\nCaught nonexistent method via autospec:\n {err}")
# 2. Calling with incorrect signature raises TypeError immediately
try:
safe_mock.charge_customer("cust_01") # Missing required argument!
except TypeError as err:
print(f"\nCaught invalid signature via autospec:\n {err}")
def main() -> None:
test_vanilla_mock_drift()
test_autospec_drift_detection()
if __name__ == "__main__":
main()Run:
uv run python autospec_drift_prevention.pyOutput:
Vanilla mock silently succeeded despite invalid method names and missing arguments.
Caught nonexistent method via autospec:
Mock object has no attribute 'non_existent_method'
Caught invalid signature via autospec:
missing a required argument: 'amount_cents'
Case 3: Testing Asynchronous Event Pipelines with AsyncMock
When testing asyncio code, methods that return coroutines cannot be replaced with synchronous Mock objects. AsyncMock ensures that calling the mock returns an awaitable coroutine and records await counts:
# async_worker_test.py
import asyncio
from unittest.mock import AsyncMock
async def process_queue_batch(consumer, alert_service) -> int:
"""Consumes messages from a stream and forwards alerts asynchronously."""
messages = await consumer.poll_messages(max_items=10)
processed_count = 0
for msg in messages:
if msg.get("severity") == "CRITICAL":
await alert_service.dispatch_pager(msg["event"])
await consumer.ack(msg["offset"])
processed_count += 1
return processed_count
async def test_async_worker() -> None:
mock_consumer = AsyncMock()
mock_alerts = AsyncMock()
# Setup async return value for poll_messages
mock_consumer.poll_messages.return_value = [
{"offset": 101, "severity": "INFO", "event": "User logged in"},
{"offset": 102, "severity": "CRITICAL", "event": "DB Connection dropped"},
{"offset": 103, "severity": "WARN", "event": "High memory consumption"},
]
# Execute async function under test
count = await process_queue_batch(mock_consumer, mock_alerts)
print(f"Total processed events: {count}")
print(f"Consumer ack() awaited count: {mock_consumer.ack.await_count}")
print(f"Alert dispatch_pager() awaited count: {mock_alerts.dispatch_pager.await_count}")
# Assert async interactions
mock_consumer.poll_messages.assert_awaited_once_with(max_items=10)
mock_alerts.dispatch_pager.assert_awaited_once_with("DB Connection dropped")
assert mock_consumer.ack.await_count == 3
print("All async assertions verified!")
if __name__ == "__main__":
asyncio.run(test_async_worker())Run:
uv run python async_worker_test.pyOutput:
Total processed events: 3
Consumer ack() awaited count: 3
Alert dispatch_pager() awaited count: 1
All async assertions verified!
Pitfalls
Pitfall 1: Patching the Definition Site Instead of the Lookup Site
If service.py contains from time import time, patching time.time has no effect because service.py holds a direct pointer to the function bound during import:
# service.py
from time import time
def get_current_epoch() -> float:
return time()
# test_service.py (THE TRAP):
from unittest.mock import patch
# WRONG: time was imported into service's namespace!
with patch("time.time", return_value=1700000000.0):
# get_current_epoch() still calls the unpatched original function!
pass
# THE FIX:
# Patch the symbol where service.py looks it up!
with patch("service.time", return_value=1700000000.0):
assert get_current_epoch() == 1700000000.0Pitfall 2: Misspelling Assertion Methods (assert_called_once_with typo)
Because standard Mock instances dynamically create any attribute on demand, misspelling an assertion method name (e.g. assert_called_once_withh) does not raise an error—it merely creates a new child mock, silently passing the test!
# THE TRAP:
from unittest.mock import Mock
mock_obj = Mock()
# Did you notice the extra 'h'?
mock_obj.assert_called_once_withh("bad_arg") # SILENT PASS! Returns a new Mock object!
# THE FIX:
# Use pytest with unittest.mock or run with PYTHONWARNINGS to catch non-existent mock assertions,
# or use autospec=True.Exercises
- Create a unit test using
@patch("os.environ.get")to verify that a configuration loader returns default fallback values when an environment variable is missing. - Build a test for a database transaction context manager (
with db.transaction(): ...) usingMagicMockto ensurecommit()is called on clean exit androllback()is called on exceptions. - Test a file download function by patching
urllib.request.urlopenwith a mock context manager that streams 1 KB chunk buffers. - Using
side_effect, implement a mock for a rate-limited API that returns HTTP 429 twice before returning HTTP 200 with valid JSON. - Convert an existing unit test that uses vanilla
Mocktocreate_autospec(TargetClass, instance=True)and observe which tests break due to out-of-date method signatures.
Further reading
- Python Documentation:
unittest.mock— Mock object library. - Harry Percival & Bob Gregory: Architecture Patterns with Python (Chapter 3: Coupling and Abstractions; Mocks vs Adapters).
- Martin Fowler: Mocks Aren’t Stubs (Fowler’s taxonomy of test doubles: Dummy, Fake, Stubs, Mocks).
- Brett Slatkin: Effective Python (Item 80: Consider
unittest.mockfor Testing Code with Dependencies).