Skip to main content

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_list
  • MagicMock vs Mock: when magic methods are auto-configured and when they are not
  • patch as a decorator, context manager, and patch.object
  • patch.dict for environment variables and sys.modules
  • spec= and autospec=True: catching attribute typos at test time
  • side_effect: exceptions, different values per call, callable side effects
  • The typo danger: assert_called_once is not the same as assert_called_once_with
  • sentinel, create_autospec, and AsyncMock
  • pytest-mock and the mocker fixture
  • When NOT to mock: boundaries only, never pure functions

Prerequisites

  • Familiarity with pytest basics (writing and running tests)
  • Python's import system: how import X binds 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:

  1. Looks up requests in sys.modules (loads it if not already loaded)
  2. Binds the name requests in payment.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
send_welcome_email("[email protected]")

# ---- 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
send_welcome_email("[email protected]")

# ---- Case 3: aliased import ----
# email_sender.py: import smtplib as smtp_lib
with patch("email_sender.smtp_lib.SMTP") as mock_smtp: # correct
send_welcome_email("[email protected]")

:::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
assert is_valid_email("[email protected]") is True
Show Answer

Two issues:

  1. Wrong patch target. The correct target is validator.re.match - because validator.py binds re in its own namespace and calls re.match through that binding.

  2. Unnecessary mock. is_valid_email is a pure function with no system boundary. This should be tested with real inputs:

def test_is_valid_email():
assert is_valid_email("[email protected]") is True
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: Mock does not configure __len__
  • len(mm)0: MagicMock pre-configures __len__ to return 0
  • list(m)TypeError: Mock does not configure __iter__
  • list(mm)[]: MagicMock pre-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")
send_notification("[email protected]", "Welcome")
mock_send.assert_called_once("[email protected]", "Welcome") # bug here

# 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")
send_notification("[email protected]", "Welcome")
# Fix: assert_called_once_with (not assert_called_once)
mock_send.assert_called_once_with("[email protected]", "Welcome")

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:

  1. Acts as a drop-in replacement for requests.Session
  2. Accepts a responses dict mapping (method, url) tuples to response dicts
  3. Raises requests.exceptions.Timeout when the URL contains "slow.api.com"
  4. Records all calls with method, URL, and body
  5. Has an assert_requested(method, url, body=None) helper that produces a readable failure message
  6. 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:

  • _call is the single dispatch point - all HTTP methods go through it, making timeout injection and call recording uniform
  • assert_requested produces a readable failure message that lists what was actually called, making debug fast
  • Unregistered URLs raise HTTPError rather 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.py does import requests, patch payment.requests.post. If it does from requests import post, patch payment.post
  • Mock records all interactions: called, call_count, call_args, call_args_list, return_value
  • MagicMock auto-configures dunder methods (__len__, __iter__, __enter__, __exit__); use it when the mock participates in Python protocols; patch() returns MagicMock by default
  • patch() as context manager gives the clearest cleanup semantics; as a decorator it injects the mock as an argument; patch.object patches a named attribute on a specific object or class
  • patch.dict patches dictionaries (including os.environ and sys.modules) and restores the original state on exit
  • side_effect overrides return_value: use it to raise exceptions, return different values per call (via an iterable), or run a callable with full logic
  • autospec=True validates both attribute existence and call signatures at test time - use it at critical system boundaries; it has a performance cost
  • assert_called_once() ignores arguments - always use assert_called_once_with() when you care about what was passed
  • Misspelled assert method names (e.g., assert_called_onc_with) return a new Mock that is always truthy - the test silently passes with no assertion made
  • AsyncMock is required for async def functions; Mock() returns a non-awaitable and causes TypeError in async contexts
  • pytest-mock's mocker fixture auto-cleans patches after each test and provides mocker.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.

© 2026 EngineersOfAI. All rights reserved.