Test in Isolation: A Practical Deep Dive into Python's unittest.mock
Learn how to write fast, isolated tests with Python's unittest.mock — Mock vs MagicMock, return_value and side_effect, the patch() decorator, the "patch where it's used" rule, autospec, and mocking files and properties.
Why mock anything at all?
A good unit test checks one piece of logic and nothing else. The trouble is that real code reaches out to the world: it calls HTTP APIs, reads files, queries databases, and looks at the clock. If your test for a describe_weather() function actually hits a weather API, the test becomes slow, flaky, and dependent on a network you don't control. When the API is down, your test fails — even though your code is fine.
Mocking is the answer. A mock is a stand-in object that records how it was used and returns whatever you tell it to. You swap the real dependency for a mock, drive it with controlled values, and then assert both the return value and the way your code interacted with the dependency. Python ships this capability in the standard library as unittest.mock, so there's nothing to install. This guide walks through the pieces you'll actually use day to day.
Mock and MagicMock: the building blocks
A plain Mock is an object that fabricates attributes and methods on demand. Every access returns another mock, and every call is recorded.
from unittest.mock import Mock
m = Mock()
m.method(1, 2, key="v") # nothing is really "done"
m.method.assert_called_once_with(1, 2, key="v") # but it was recorded
You configure what a mock returns with return_value, and you can script a sequence of outcomes (or an exception) with side_effect:
calc = Mock()
# Always return the same thing:
calc.add.return_value = 42
assert calc.add(1, 1) == 42
# Return different values on successive calls:
calc.get.side_effect = [10, 20, 30]
calc.get(), calc.get(), calc.get() # -> 10, 20, 30
# Raise an exception:
calc.load.side_effect = FileNotFoundError("missing")
If side_effect is a function, the mock calls it with the same arguments and uses its return value — handy when the response should depend on the input.
MagicMock is a Mock subclass that also supports Python's "magic" dunder methods, so it works with len(), iteration, context managers, and comparisons out of the box. When in doubt, reach for MagicMock — it's what patch() uses by default.
from unittest.mock import MagicMock
mm = MagicMock()
mm.__len__.return_value = 5
assert len(mm) == 5 # a plain Mock would raise TypeError here
patch(): replacing something for the duration of a test
Creating a mock is only half the job — you still need to slot it in where the real object lives. patch() does this temporarily and cleans up automatically afterward. It works as a decorator, a context manager, or (less often) manually.
import os
from unittest.mock import patch
@patch("os.getcwd")
def test_cwd(mock_getcwd): # the mock is injected as an argument
mock_getcwd.return_value = "/fake"
assert os.getcwd() == "/fake"
mock_getcwd.assert_called_once()
As a context manager, the patch only applies inside the with block:
with patch("os.getcwd", return_value="/fake") as mock_getcwd:
assert os.getcwd() == "/fake"
# outside the block, os.getcwd is the real thing again
When you stack multiple @patch decorators, remember the arguments are injected bottom-up: the decorator closest to the function maps to the first parameter.
The single most important rule: patch where it's used
This is the trap that catches nearly everyone. You patch a name based on where it is looked up, not where it is defined. If a module does from urllib.request import urlopen, then that module now has its own reference called urlopen, and patching urllib.request.urlopen won't touch it.
Say you have weather.py:
# weather.py
def get_temp(city):
... # calls a real API
def describe(city):
t = get_temp(city)
return f"{city}: {t}°C" + (" (freezing)" if t <= 0 else "")
To test describe() without a network, patch get_temp in the weather namespace — the place describe reads it from:
# test_weather.py
from unittest.mock import patch
import weather
@patch("weather.get_temp") # NOT "some_api.get_temp"
def test_describe_freezing(mock_get_temp):
mock_get_temp.return_value = -3
assert weather.describe("Oslo") == "Oslo: -3°C (freezing)"
mock_get_temp.assert_called_once_with("Oslo")
Internalize this and you'll avoid the most common "why isn't my mock working?" headache.
patch.object: reach into a class or module
When you want to replace a specific attribute of an object you already have, patch.object is cleaner than building a string path:
from unittest.mock import patch
class Service:
def fetch(self):
return "real data"
s = Service()
with patch.object(Service, "fetch", return_value="fake data"):
assert s.fetch() == "fake data"
assert s.fetch() == "real data" # restored automatically
Asserting on how a mock was used
Mocks shine because they remember everything. Beyond assert_called_once_with, you have a whole family of inspection tools:
from unittest.mock import Mock, call
logger = Mock()
logger.info("starting")
logger.info("done")
logger.info.assert_called_with("done") # the most recent call
assert logger.info.call_count == 2
assert logger.info.call_args_list == [call("starting"), call("done")]
Use assert_called_once_with to demand exactly one call with specific arguments, assert_not_called to prove a branch was skipped, and assert_any_call when order doesn't matter. The call helper builds comparable records of arguments, which makes multi-call assertions readable.
Mocking file access with mock_open
Testing code that reads files is a classic pain point. mock_open gives you a ready-made replacement for the built-in open, complete with fake file contents:
from unittest.mock import patch, mock_open
fake = mock_open(read_data="line1\nline2")
with patch("builtins.open", fake):
with open("whatever.txt") as f:
contents = f.read()
assert contents == "line1\nline2"
Your production code never touches the disk, and the test runs in microseconds.
Mocking a property with PropertyMock
Properties look like attributes but run code, so a normal return_value won't cut it. Use PropertyMock via new_callable:
from unittest.mock import patch, PropertyMock
class Account:
@property
def balance(self):
... # expensive lookup
with patch.object(Account, "balance", new_callable=PropertyMock,
return_value=100):
assert Account().balance == 100
autospec: mocks that respect real signatures
A plain mock accepts any call, which means a test can pass even if the underlying function changed its arguments. That's a false sense of safety. Passing autospec=True builds a mock that mirrors the real object's signature and raises if you call it incorrectly:
from unittest.mock import patch
def api(user, active=True):
...
with patch(f"{__name__}.api", autospec=True) as mock_api:
mock_api("bob") # fine
mock_api(1, 2, 3) # raises TypeError: too many positional args
Reaching for autospec (or the related create_autospec) is a cheap way to keep your mocks honest as the code they stand in for evolves.
Common pitfalls
A few traps are worth calling out explicitly. First, the patch-where-it's-used rule above — it accounts for the majority of confusing failures. Second, over-mocking: if a test mocks so much that it only verifies your mocks talk to each other, it no longer tells you whether the code works; mock at the boundaries (network, disk, clock) and let pure logic run for real. Third, forgetting to configure return_value — an unconfigured method returns a fresh MagicMock, which is truthy and will silently pass an if check you expected to fail. Finally, be wary of asserting on internal call patterns that aren't part of the contract; tests that pin every implementation detail break every time you refactor.
Wrap-up and next steps
unittest.mock gives you everything needed to isolate the unit under test: Mock and MagicMock for stand-in objects, return_value and side_effect to script behavior, patch and patch.object to swap dependencies temporarily, a rich set of assert_called* methods to verify interactions, and helpers like mock_open, PropertyMock, and autospec for the tricky cases. The whole toolkit is in the standard library and integrates seamlessly with both unittest and pytest.
From here, explore pytest's monkeypatch fixture and the pytest-mock plugin, which wrap the same machinery in a more fixture-friendly style. And whenever a mock feels awkward to set up, treat it as a design signal: code that's hard to test in isolation is often code with too many tangled dependencies. Cleaner seams make for cleaner tests.