pyguides

Mocking Python: Test Doubles, patch, and MonkeyPatch in pytest

Intro context

Mocking Python tests means replacing a real dependency with a controlled stand-in so the test stays fast and deterministic. Real code talks to databases, hits APIs, reads files, and calls other services. In tests you want those interactions to be predictable, not flaky. Mocking Python’s standard library is built around unittest.mock, and pytest layers a MonkeyPatch helper on top for the same job.

This tutorial walks through when to reach for a mock, how MagicMock and Mock objects record calls, using patch as a decorator and a context manager, mocking attribute lookups versus call results, and the common pitfalls (patching the wrong import path, over-mocking, and asserting on the wrong call shape). See also the pytest basics tutorial and the unittest.mock guide.

When to mock

Mock when:

  • The dependency is slow (network calls, large file reads)
  • The dependency has non-deterministic behaviour (random values, time)
  • The dependency calls external services (payment APIs, email providers)
  • Setting up the real dependency is complicated (a database with specific schema)

Don’t mock when:

  • You’re testing the integration itself (you actually need the database)
  • The mock would be as complex as the real thing
  • You’re doing end-to-end or integration tests

The mock object

MagicMock creates callable objects with configurable return values:

from unittest.mock import MagicMock

# Create a mock that returns "hello" when called
fake_api = MagicMock()
fake_api.get_user.return_value = {"id": 1, "name": "Alice"}

# Call it
result = fake_api.get_user(user_id=42)
print(result)  # {"id": 1, "name": "Alice"}

# Check how it was called
fake_api.get_user.assert_called_once_with(user_id=42)

Every attribute and method access on a MagicMock returns another MagicMock, so you can chain calls without errors:

fake_db = MagicMock()
fake_db.users.where.return_value.first.return_value = {"name": "Bob"}
print(fake_db.users.where.return_value.first.return_value)  # {"name": "Bob"}

Patch as a context manager

patch temporarily replaces a name in a module with a mock:

from unittest.mock import patch

@patch("requests.get")
def test_fetch_user(mock_get):
    mock_get.return_value.json.return_value = {"name": "Carol", "id": 99}

    result = fetch_user(99)  # your function that calls requests.get

    assert result == {"name": "Carol", "id": 99}
    mock_get.assert_called_once_with("http://api.example.com/users/99")

patch targets the name as it appears in the code being tested, not where it’s imported from. If your code does import requests and then calls requests.get(), you patch requests.get. If it does from requests import get and calls get(), you patch get.

Patch with object methods

Patch a method on an instance by targeting the class and method name:

@patch.object(DatabaseConnection, "query")
def test_database_query(mock_query):
    mock_query.return_value = [{"id": 1, "name": "Alice"}]

    db = DatabaseConnection("localhost")
    result = db.query("SELECT * FROM users")

    assert result == [{"id": 1, "name": "Alice"}]

Mocking context managers

If your code uses with statements, mock the return value of __enter__:

fake_file = MagicMock()
fake_file.read.return_value = "hello world"
fake_file.__enter__.return_value = fake_file

with patch("builtins.open", return_value=fake_file):
    content = read_file("data.txt")
    assert content == "hello world"

Or use patch with mock_open for a cleaner approach:

from unittest.mock import mock_open

with patch("builtins.open", mock_open(read_data="hello world")):
    content = read_file("data.txt")
    assert content == "hello world"

Side_effect for multiple calls

side_effect lets you return different values on successive calls, or raise exceptions:

mock = MagicMock()
mock.fetch.side_effect = [
    {"status": "pending"},
    {"status": "done"},
]

print(mock.fetch())   # {"status": "pending"}
print(mock.fetch())  # {"status": "done"}

Raise an exception:

mock = MagicMock()
mock.connect.side_effect = ConnectionError("Connection refused")

with pytest.raises(ConnectionError):
    mock.connect()

Spy: mock + real call

spy wraps a real function so you can assert it was called while still running the original code:

from unittest.mock import patch, call

@patch("mymodule.send_email", wraps=mymodule.send_email)
def test_email_is_sent(mock_send):
    send_welcome_email("alice@example.com")

    mock_send.assert_called_once_with(
        to="alice@example.com",
        subject="Welcome!"
    )

The real send_email actually runs. You just get to see that it was called and with what arguments.

pytest monkeypatch

pytest’s MonkeyPatch is a fixture for the same tricks in pytest-style tests:

def test_database(monkeypatch):
    fake_db = MagicMock()
    fake_db.query.return_value = [{"id": 1, "name": "Alice"}]
    monkeypatch.setattr("mymodule.database", fake_db)

    result = get_user_count()
    assert result == 1

monkeypatch.setattr takes a string path to the attribute. monkeypatch.delattr removes attributes.

Cleaning up mocks

pytest fixture scopes handle cleanup automatically. With @patch, the original is restored after the test finishes. With monkeypatch, cleanup happens automatically at test teardown.

If you’re using mocks with setup_method, make sure mocks persist correctly across tests:

class TestOrder:
    def setup_method(self):
        self.fake_api = MagicMock()
        self.fake_api.get_price.return_value = 9.99

    def test_order_price(self, monkeypatch):
        monkeypatch.setattr(pricing, "get_price", self.fake_api.get_price)
        order = Order()
        assert order.price == 9.99

Common mistakes

Patching at the wrong location. If code does from urllib.request import urlopen and calls urlopen(), you patch urllib.request.urlopen, not urllib.request and not the import name urlopen.

Not resetting mocks between tests. If you’re reusing a mock across tests, call mock.reset_mock() in setup_method:

def setup_method(self):
    self.fake_api = MagicMock()
    self.fake_api.get_user.return_value = {"id": 1}

def test_first(self):
    result = get_user(1)
    assert result["id"] == 1

def test_second(self):
    # Without reset, the mock still has the old call recorded
    # Use reset_mock if you need a clean slate
    self.fake_api.reset_mock()

Over-mocking. If your test is full of complex mocks that barely reflect what the code actually does, the mocks are making the tests fragile. Only mock what you actually need to control.

See also

Where to go next

With mocking Python tests under your belt, build up the rest of the testing toolkit: cover assertion patterns and runners in the pytest basics tutorial, explore parametric tests in the fixtures and parametrize tutorial, and learn about coverage trade-offs in the coverage and mutation tutorial.