Both replace an attribute. Both undo it afterwards. Most codebases end up with both, chosen by whichever the author saw last.
There is a cleaner line to draw.
The question
Do you need to assert on how it was called?
-
No — you just need it to return something, or to not happen. Use
monkeypatch. -
Yes — you want
assert_called_once_with. Usemock.patch/MagicMock.
That covers the great majority of real cases.
When you only need a value
monkeypatch is a pytest fixture. It reads as plain Python, and it undoes everything at the end of the test automatically.
def test_uses_configured_region(monkeypatch):
monkeypatch.setenv("AWS_REGION", "ap-northeast-1")
assert build_client().region == "ap-northeast-1"
def test_handles_missing_file(monkeypatch):
monkeypatch.setattr(Path, "exists", lambda self: False)
assert load_config() == DEFAULTS
Note setenv and delenv. This is the part mock.patch handles awkwardly (patch.dict(os.environ, ...)) and it comes up constantly.
monkeypatch also has chdir and syspath_prepend, both of which are genuinely painful to do by hand and restore correctly.
When you need to inspect the call
from unittest.mock import patch
def test_sends_welcome_email():
with patch("myapp.users.send_email") as mock_send:
register_user("a@example.com")
mock_send.assert_called_once_with(
to="a@example.com",
template="welcome",
)
monkeypatch.setattr with a plain lambda can't do this. You'd end up hand-rolling a recorder — which is exactly what MagicMock already is.
The mistake both share
Patch where the name is looked up, not where it is defined.
# myapp/users.py
from myapp.email import send_email # bound at import time
patch("myapp.email.send_email") # too late, users.py already has its own reference
patch("myapp.users.send_email") # correct
This trips people up with monkeypatch.setattr in exactly the same way:
monkeypatch.setattr("myapp.users.send_email", fake) # correct
If the import in the module under test is import myapp.email and the call site is myapp.email.send_email(...), then patching myapp.email.send_email is right — because the lookup happens at call time. The rule is about the lookup, not about the file.
A practical hybrid
You can get monkeypatch's cleanup with MagicMock's assertions:
def test_sends_welcome_email(monkeypatch):
mock_send = MagicMock()
monkeypatch.setattr("myapp.users.send_email", mock_send)
register_user("a@example.com")
mock_send.assert_called_once_with(to="a@example.com", template="welcome")
No context manager, no decorator stacking, full assertion API. When a test needs three patches, this reads considerably better than three nested with blocks.
The summary
| Need | Tool |
|---|---|
| Environment variables | monkeypatch.setenv |
| Working directory | monkeypatch.chdir |
| Stub a return value | monkeypatch.setattr |
| Assert on arguments |
MagicMock (installed either way) |
| Patch across many tests |
patch as a decorator, or an autouse fixture |
Pick per test based on what you're asserting, not per codebase based on convention.
These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.
Top comments (0)