Python Mock and Patch: Unit Testing External Dependencies
Unit tests should run fast and in isolation — no real databases, no HTTP calls, no filesystem side effects. Python's unittest.mock module provides MagicMock, AsyncMock, and the patch() decorator to replace real dependencies with controllable test doubles. Mastering these tools lets you test business logic thoroughly even when it sits behind layers of external services, while keeping your test suite fast enough to run on every commit.
Table of Contents
MagicMock Basics
MagicMock is a flexible fake object that records all calls made to it and returns new MagicMock objects for any attribute access or method call. You configure it by setting return_value for what a call should return and side_effect for raising exceptions or dynamic behaviour. Every interaction is tracked and can be asserted afterwards.
from unittest.mock import MagicMock, call
# Basic mock — auto-creates attributes and methods
mock = MagicMock()
mock.greet("Alice")
mock.greet.assert_called_once_with("Alice")
# Set return value
mock.get_user.return_value = {"id": 1, "name": "Alice"}
result = mock.get_user(1)
print(result) # {"id": 1, "name": "Alice"}
# Configure chained calls
mock.db.session.query.return_value.filter.return_value.first.return_value = {"id": 1}
user = mock.db.session.query("users").filter(id=1).first()
print(user) # {"id": 1}
# Assert calls
mock_service = MagicMock()
mock_service.process(1)
mock_service.process(2)
mock_service.process(3)
mock_service.process.assert_called()
mock_service.process.assert_called_with(3) # last call
assert mock_service.process.call_count == 3
mock_service.process.assert_any_call(2)
# Assert call order using call list
expected_calls = [call(1), call(2), call(3)]
mock_service.process.assert_has_calls(expected_calls)
patch() Decorator and Context Manager
patch() temporarily replaces an object in a module's namespace with a MagicMock for the duration of a test. The key insight is that you patch where the name is used, not where it is defined. If myapp.service imports requests, you patch myapp.service.requests, not requests.
import pytest
from unittest.mock import patch, MagicMock
# myapp/service.py
import requests
def fetch_user(user_id: int) -> dict:
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
return response.json()
def send_notification(user_id: int, message: str) -> bool:
user = fetch_user(user_id)
response = requests.post(
"https://notify.example.com/send",
json={"to": user["email"], "message": message}
)
return response.status_code == 200
# Tests — patch where requests is used (myapp.service.requests)
@patch("myapp.service.requests.get")
def test_fetch_user(mock_get):
mock_get.return_value.json.return_value = {"id": 1, "name": "Alice", "email": "a@b.com"}
mock_get.return_value.raise_for_status.return_value = None
result = fetch_user(1)
assert result == {"id": 1, "name": "Alice", "email": "a@b.com"}
mock_get.assert_called_once_with("https://api.example.com/users/1")
# As context manager
def test_send_notification():
with patch("myapp.service.requests.get") as mock_get, \
patch("myapp.service.requests.post") as mock_post:
mock_get.return_value.json.return_value = {"email": "alice@example.com"}
mock_get.return_value.raise_for_status.return_value = None
mock_post.return_value.status_code = 200
result = send_notification(1, "Hello!")
assert result is True
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
assert kwargs["json"]["to"] == "alice@example.com"
# patch.object — patch a specific object's attribute
from myapp.service import SomeClass
def test_method():
obj = SomeClass()
with patch.object(obj, "expensive_method", return_value=42):
result = obj.do_work()
assert result == 42
side_effect: Exceptions and Dynamic Returns
side_effect overrides a mock's return behaviour. Assign an exception class or instance to raise it on call. Assign a callable to compute return values dynamically. Assign an iterable to return successive values — or raise exceptions — on sequential calls. This is essential for testing retry logic, error handling, and conditional behaviour.
from unittest.mock import MagicMock, patch
import requests
# Raise an exception
mock = MagicMock()
mock.get_data.side_effect = ConnectionError("Network unreachable")
with pytest.raises(ConnectionError):
mock.get_data()
# Return different values on successive calls
mock.fetch.side_effect = [
{"status": "pending"},
{"status": "pending"},
{"status": "complete"},
]
assert mock.fetch()["status"] == "pending"
assert mock.fetch()["status"] == "pending"
assert mock.fetch()["status"] == "complete"
# Mix values and exceptions
mock.process.side_effect = [
{"id": 1},
ValueError("Invalid"),
{"id": 2},
]
assert mock.process() == {"id": 1}
with pytest.raises(ValueError):
mock.process()
assert mock.process() == {"id": 2}
# Dynamic side_effect with a function
def fake_get(url, **kwargs):
if "users" in url:
return MagicMock(json=lambda: {"id": 1}, status_code=200)
elif "orders" in url:
return MagicMock(json=lambda: {"order_id": 99}, status_code=200)
raise ValueError(f"Unknown URL: {url}")
@patch("myapp.service.requests.get", side_effect=fake_get)
def test_multiple_endpoints(mock_get):
user = fetch_user(1)
assert user["id"] == 1
spec and autospec
By default, MagicMock accepts any attribute or method call, even ones that don't exist on the real object. This can hide bugs — if you rename a method in production but forget to update a test, the test still passes because the mock silently accepts the old name. Using spec or create_autospec() restricts the mock to the real interface and catches these mistakes at test time.
from unittest.mock import MagicMock, create_autospec, patch
import requests
class UserService:
def get_user(self, user_id: int) -> dict: ...
def create_user(self, name: str, email: str) -> dict: ...
def delete_user(self, user_id: int) -> bool: ...
# Without spec — silently accepts typos
bad_mock = MagicMock()
bad_mock.get_usr(1) # typo — no error!
# With spec — raises AttributeError for non-existent attributes
spec_mock = MagicMock(spec=UserService)
spec_mock.get_user(1) # OK
try:
spec_mock.get_usr(1) # AttributeError: spec_mock has no attribute 'get_usr'
except AttributeError as e:
print(e)
# create_autospec — also validates call signatures
auto_mock = create_autospec(UserService)
auto_mock.get_user(1) # OK
try:
auto_mock.get_user(1, 2) # TypeError: too many arguments
except TypeError as e:
print(e)
# Use in patch
@patch("myapp.service.UserService", spec=UserService)
def test_with_spec(MockService):
instance = MockService.return_value
instance.get_user.return_value = {"id": 1, "name": "Alice"}
# This would fail at test time if get_user signature changed
result = instance.get_user(1)
assert result["name"] == "Alice"
AsyncMock for Coroutines
AsyncMock (added in Python 3.8) creates a mock that is itself an awaitable coroutine. Use it whenever you need to mock an async def function, an async context manager, or an async iterator. patch() automatically uses AsyncMock when the target is a coroutine function (Python 3.8+).
import pytest
import asyncio
from unittest.mock import AsyncMock, patch, MagicMock
# Async function under test
async def get_user_with_cache(user_id: int, cache, db) -> dict:
cached = await cache.get(f"user:{user_id}")
if cached:
return cached
user = await db.fetch_user(user_id)
await cache.set(f"user:{user_id}", user, ttl=300)
return user
@pytest.mark.asyncio
async def test_cache_hit():
mock_cache = AsyncMock()
mock_db = AsyncMock()
mock_cache.get.return_value = {"id": 1, "name": "Alice"}
result = await get_user_with_cache(1, mock_cache, mock_db)
assert result == {"id": 1, "name": "Alice"}
mock_cache.get.assert_awaited_once_with("user:1")
mock_db.fetch_user.assert_not_awaited() # DB not hit on cache hit
@pytest.mark.asyncio
async def test_cache_miss():
mock_cache = AsyncMock()
mock_db = AsyncMock()
mock_cache.get.return_value = None # cache miss
mock_db.fetch_user.return_value = {"id": 1, "name": "Bob"}
result = await get_user_with_cache(1, mock_cache, mock_db)
assert result["name"] == "Bob"
mock_db.fetch_user.assert_awaited_once_with(1)
mock_cache.set.assert_awaited_once_with("user:1", {"id": 1, "name": "Bob"}, ttl=300)
# Async context manager mock
@pytest.mark.asyncio
async def test_async_context_manager():
mock_conn = AsyncMock()
mock_conn.__aenter__.return_value = mock_conn
mock_conn.__aexit__.return_value = None
mock_conn.fetchrow.return_value = {"id": 42}
async with mock_conn as conn:
row = await conn.fetchrow("SELECT 1")
assert row == {"id": 42}
Call Assertions
Asserting that mocks were called correctly is as important as asserting return values. The mock library provides a rich set of assertion methods and the call helper for precise call matching. Check not just that a method was called, but with the right arguments and in the right order.
from unittest.mock import MagicMock, call, ANY
mock = MagicMock()
mock.process(1, status="active")
mock.process(2, status="inactive")
mock.process(3, status="active")
# Basic assertions
mock.process.assert_called() # called at least once
mock.process.assert_called_once() # called exactly once — FAILS (called 3 times)
assert mock.process.call_count == 3
mock.process.assert_any_call(2, status="inactive") # called with these args at some point
mock.process.assert_called_with(3, status="active") # last call had these args
# Assert all calls in order
mock.process.assert_has_calls([
call(1, status="active"),
call(2, status="inactive"),
call(3, status="active"),
])
# Use ANY for wildcard matching
mock2 = MagicMock()
mock2.log("error", "Something failed", timestamp=1234567890)
mock2.log.assert_called_with("error", ANY, timestamp=ANY)
# Reset call history between test phases
mock.reset_mock()
assert mock.process.call_count == 0
# assert_not_called
mock3 = MagicMock()
mock3.send_email.assert_not_called() # passes — never called
Best Practices
Effective mocking requires discipline. Over-mocking leads to tests that test the mock, not the code. Under-mocking makes tests slow and fragile. The right balance is to mock at the boundaries of your system — external HTTP calls, database queries, message queues — while letting your business logic run for real in tests.
# GOOD: Mock at the boundary (HTTP client, DB pool)
@patch("myapp.clients.httpx.AsyncClient.get")
async def test_business_logic(mock_get):
mock_get.return_value = AsyncMock(
status_code=200,
json=lambda: {"score": 0.95}
)
result = await calculate_risk_score(user_id=1)
assert result.risk_level == "low"
# BAD: Mock internal implementation details
@patch("myapp.service._calculate_internal_score") # fragile!
async def test_too_deep(mock_calc):
mock_calc.return_value = 0.95
result = await calculate_risk_score(user_id=1)
assert result.risk_level == "low"
# Use pytest-mock for cleaner syntax (wrapper around unittest.mock)
# pip install pytest-mock
def test_with_mocker(mocker):
mock_get = mocker.patch("myapp.service.requests.get")
mock_get.return_value.json.return_value = {"id": 1}
mock_get.return_value.status_code = 200
result = fetch_user(1)
assert result["id"] == 1
mock_get.assert_called_once()