A test does sys.modules["boto3"] = MagicMock() to dodge a slow import, passes locally, then the next test that imports boto3 for real gets the mock — and the suite fails only under random ordering or pytest-xdist. The same trap appears when patching open or print: assign in the wrong namespace and the override silently does nothing, or assign without restoration and it bleeds across the whole run. sys.modules and builtins are process-global, so the fix is always the same shape: snapshot-and-restore via patch.dict/patch.object (or monkeypatch), and target the exact namespace where the name is resolved.
Prerequisites
- Python 3.8+ (
unittest.mock.mock_open,patch.dict, andpatch.objectare all standard since 3.3). pytest7.0+ formonkeypatchexamples; theunittest.mockexamples need no third-party packages.- Optional:
pytest-xdistif you want to validate isolation under parallel workers.
Solution
sys.modules is the interpreter's import cache: an import x first checks sys.modules["x"] and returns the cached object if present. A raw assignment mutates that global and is never undone. patch.dict snapshots the dictionary, applies your overrides, and restores it on exit — even if the test raises:
import sys
from unittest.mock import patch, MagicMock
def test_isolated_sys_modules():
fake = MagicMock()
fake.region.return_value = "us-east-1"
# patch.dict snapshots sys.modules and restores it on exit.
with patch.dict(sys.modules, {"slow_sdk": fake}):
import slow_sdk # resolves to `fake` from the cache
assert slow_sdk.region() == "us-east-1"
# Outside the block, sys.modules no longer contains the fake.
assert "slow_sdk" not in sys.modules or sys.modules["slow_sdk"] is not fake
Under pytest, monkeypatch.setitem is the idiomatic equivalent and is reverted automatically in fixture teardown:
import sys
from unittest.mock import MagicMock
def test_with_monkeypatch(monkeypatch):
fake = MagicMock()
monkeypatch.setitem(sys.modules, "slow_sdk", fake) # auto-restored on teardown
import slow_sdk
assert slow_sdk is fake
Patching a builtin has a second pitfall on top of restoration: targeting. CPython resolves a name like open by walking the consuming module's globals and then builtins, so you must patch where the code looks it up. The simplest correct target is builtins.open (or mymod.open if the module did from builtins import open-style rebinding). mock_open builds a file double that already supports the context-manager and iteration protocols:
from unittest.mock import patch, mock_open
def load_first_line(path):
with open(path) as fh: # this `open` resolves to builtins.open
return fh.readline()
def test_open_with_mock_open():
m = mock_open(read_data="line-1\nline-2\n")
# Patch the builtin where the function resolves it.
with patch("builtins.open", m):
assert load_first_line("ignored.txt") == "line-1\n"
m.assert_called_once_with("ignored.txt") # call is recorded
For builtins whose call signature matters (so a wrong-arity call fails the test the way it would in production), add autospec=True via patch.object:
import builtins
from unittest.mock import patch
def test_print_signature_enforced(capsys):
with patch.object(builtins, "print", autospec=True) as mock_print:
print("hello", "world", sep="-") # valid call against the real signature
mock_print.assert_called_once_with("hello", "world", sep="-")
When you must coordinate several global patches, contextlib.ExitStack (or stacked with) guarantees reverse-order teardown even if one patch raises mid-setup:
import sys, builtins
from contextlib import ExitStack
from unittest.mock import patch, MagicMock, mock_open
def test_multiple_global_patches():
with ExitStack() as stack:
stack.enter_context(patch.dict(sys.modules, {"slow_sdk": MagicMock()}))
stack.enter_context(patch("builtins.open", mock_open(read_data="x")))
# ...exercise code that touches both globals...
import slow_sdk
assert slow_sdk is sys.modules["slow_sdk"]
# All patches reverted in reverse registration order here.
Why this works
patch.dict and monkeypatch.setitem treat the global as a transaction: they record the prior value (or absence) of each key and write it back on teardown, so leakage is impossible regardless of test order or exceptions. Targeting builtins.open rather than a stale per-module reference works because the function's bytecode performs the lookup at call time through its module globals into builtins, which is exactly the object you replaced. Choosing mock_open over a hand-wired double matters because file usage almost always goes through the with context-manager protocol, which mock_open implements for you.
Edge cases and failure modes
- Module already imported before the patch. If
slow_sdkwas imported during pytest collection,patch.dictreplaces the cached object but any module that already boundfrom slow_sdk import thingkeeps its old reference. Patch before first import, or patch the bound name in the consuming module. - Wrong builtin namespace. Patching
builtins.openhas no effect on a module that did something unusual to rebindopenlocally; confirm the target withinspect.getmoduleor by patching the consuming module'sopendirectly (patch("mymod.open")). patch().start()withoutstop(). Callingstart()outside a context manager and forgettingstop()(oraddCleanup(p.stop)) is the classic leak. Prefer thewithform ormonkeypatch, which cannot forget.autospecon C-level builtins. A few builtins have signatures that introspection cannot fully model; ifautospec=Trueraises during setup, fall back to a plain mock ormock_openand assert call args manually.- Async teardown ordering. Patching globals around
awaitboundaries can outlive the coroutine if the patch context exits before a scheduled callback runs; scope patches to the awaited region. This overlaps with the "Event loop is closed" failures discussed under systematic debugging and performance profiling.
Frequently Asked Questions
Why does my mock leak into other tests after patching sys.modules?
Because a bare assignment like sys.modules['pkg'] = MagicMock() is never reverted. sys.modules is a process-global cache, so the next import of pkg returns your mock. Use patch.dict(sys.modules, ...) or monkeypatch.setitem, both of which restore the original on teardown.
Where do I patch a builtin like open so the override actually takes effect?
Patch it in the namespace where the code under test looks it up. For most code that is builtins.open or the consuming module's namespace (mymod.open). mock_open() from unittest.mock gives a ready-made file handle that supports read, readline, and iteration.
Do I still need to clean up sys.modules under pytest-xdist?
Yes. xdist isolates workers in separate processes but each worker runs many tests in one interpreter, so within-worker leakage still happens. Use patch.dict or monkeypatch so teardown reverts the cache regardless of worker.
Getting the namespace right is the heart of the broader skill covered in patching strategies for complex codebases and its companion on where to patch. The mock_open and MagicMock doubles used here behave per the rules in the deep dive into unittest.mock, and adding autospec=True ties into autospec and strict mocking for signature-accurate builtins.