DEV Community

MSakai
MSakai

Posted on

monkeypatch or mock.patch? One question decides it

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. Use mock.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
Enter fullscreen mode Exit fullscreen mode

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",
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
patch("myapp.email.send_email")   # too late, users.py already has its own reference
patch("myapp.users.send_email")   # correct
Enter fullscreen mode Exit fullscreen mode

This trips people up with monkeypatch.setattr in exactly the same way:

monkeypatch.setattr("myapp.users.send_email", fake)   # correct
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)