Mocking API Responses in Python Tests
Two Ways to Fake a Dependency
The previous lesson used a hand-written class — FakeClientForSummarize — to stand in for the real OpenAI client. That approach is called a fake: a simplified but real implementation of the same interface, built by you, that behaves consistently according to rules you wrote. Python's standard library offers a second approach through the unittest.mock module: a mock, which is a generic object that records how it was called and returns whatever you configure, without you writing a real class at all.
Both techniques solve the same underlying problem — replacing the real client.responses.create(...) call with something fast, free, and deterministic — but they trade off differently, and knowing when to reach for each one is a practical skill this lesson builds.
unittest.mock.Mock and MagicMock
Mock (and its more permissive sibling MagicMock) is an object that accepts any attribute access or method call and, by default, returns another Mock object. You configure the specific behavior you need by setting attributes or using return_value.
from unittest.mock import MagicMock
def summarize(client, text: str) -> str:
response = client.responses.create(
model="gpt-5.6-terra",
input=f"Summarize this in one sentence:\n\n{text}",
)
return response.output_text.strip()
def test_summarize_with_magicmock():
fake_client = MagicMock()
fake_client.responses.create.return_value.output_text = " Concise summary. "
result = summarize(fake_client, "Some article text.")
assert result == "Concise summary."
print("PASS: summarize works with a MagicMock-based client")
fake_client = MagicMock() creates an object where fake_client.responses is automatically another MagicMock, and fake_client.responses.create is callable and also a MagicMock. Setting .return_value.output_text configures what calling create(...) returns: an object whose .output_text attribute is the string you specified. This is remarkably little code compared to writing a FakeResponse and FakeClient class by hand, which is the main appeal of MagicMock for quick, narrowly-scoped tests.
Asserting on How a Mock Was Called
Mocks automatically record every call made to them, which lets you verify not just what your code returns, but how it used its dependency — exactly the kind of check the previous lesson made by hand with last_kwargs.
from unittest.mock import MagicMock
def test_summarize_calls_create_with_expected_model():
fake_client = MagicMock()
fake_client.responses.create.return_value.output_text = "ok"
summarize(fake_client, "Text to summarize.")
fake_client.responses.create.assert_called_once()
_, kwargs = fake_client.responses.create.call_args
assert kwargs["model"] == "gpt-5.6-terra"
assert "Text to summarize." in kwargs["input"]
print("PASS: summarize calls create() with the correct model and input")
assert_called_once() fails the test if create was called zero times or more than once — useful for catching bugs like an accidental retry loop that calls the API twice for one logical request. call_args holds the positional and keyword arguments from the most recent call, letting you inspect exactly what was sent. This kind of assertion catches an entire class of bugs — wrong model string, missing parameter, malformed prompt — that a test only checking the final return value would miss entirely.
Patching: Replacing a Real Object Temporarily
Dependency injection (Lesson 2) is the preferred design because it makes tests straightforward. But sometimes you are testing code you cannot easily refactor — a third-party library, legacy code, or a function that constructs its own client internally, as in summarize_bad from the previous lesson. unittest.mock.patch handles this case by temporarily replacing an object at a given import path for the duration of a test.
from unittest.mock import patch, MagicMock
def summarize_bad(text: str) -> str:
from openai import OpenAI
client = OpenAI()
response = client.responses.create(
model="gpt-5.6-terra",
input=f"Summarize this in one sentence:\n\n{text}",
)
return response.output_text
@patch("openai.OpenAI")
def test_summarize_bad_with_patch(mock_openai_class):
mock_instance = MagicMock()
mock_instance.responses.create.return_value.output_text = "Patched summary."
mock_openai_class.return_value = mock_instance
result = summarize_bad("Some text.")
assert result == "Patched summary."
print("PASS: summarize_bad works when OpenAI() is patched")
@patch("openai.OpenAI") replaces the OpenAI class, at the exact import path used inside summarize_bad, with a MagicMock for the duration of the test, then restores the original afterward automatically — even if the test raises an exception. The patched class is passed into the test function as an argument (mock_openai_class); configuring mock_openai_class.return_value controls what OpenAI() returns when summarize_bad calls it. This works, but notice it is more fragile than dependency injection: the patch target ("openai.OpenAI") must exactly match where the name is looked up, not necessarily where it is defined, which is a frequent source of confusing patch failures — patching "openai.OpenAI" does nothing if the code being tested did from openai import OpenAI at module import time, because in that case you must patch the name inside the module that imported it (for example "myapp.services.OpenAI").
patch can also be used as a context manager instead of a decorator, which is often clearer when you only need the mock for part of a test:
from unittest.mock import patch, MagicMock
def test_summarize_bad_with_patch_context_manager():
with patch("openai.OpenAI") as mock_openai_class:
mock_instance = MagicMock()
mock_instance.responses.create.return_value.output_text = "Context-managed."
mock_openai_class.return_value = mock_instance
result = summarize_bad("Some text.")
assert result == "Context-managed."
print("PASS: summarize_bad works with patch as a context manager")
pytest's monkeypatch Fixture
pytest provides its own patching mechanism, monkeypatch, as a built-in fixture. It is often preferred within pytest-based suites because it integrates directly with fixture injection and automatically undoes every change at the end of the test, with no need for a decorator or with block.
def test_summarize_bad_with_monkeypatch(monkeypatch):
mock_instance = MagicMock()
mock_instance.responses.create.return_value.output_text = "Monkeypatched."
import openai
monkeypatch.setattr(openai, "OpenAI", lambda **kwargs: mock_instance)
result = summarize_bad("Some text.")
assert result == "Monkeypatched."
print("PASS: summarize_bad works with monkeypatch")
monkeypatch.setattr(openai, "OpenAI", ...) replaces the OpenAI attribute on the openai module object directly, for the duration of the current test only. Because monkeypatch is a fixture, pytest automatically restores the original attribute after the test finishes, regardless of whether the test passed, failed, or raised an unexpected error — you never need to remember a manual cleanup step.
Fakes Versus Mocks: When to Use Each
| Aspect | Hand-written fake | Mock / MagicMock |
|---|---|---|
| Setup effort | More code upfront | Very little code |
| Behavior realism | You control exact, consistent behavior | Permissive; can silently accept wrong calls |
| Catches interface drift | Yes, if kept in sync with the real client | No, MagicMock accepts any attribute or call |
| Best for | Reused across many tests; complex stateful behavior | Small, one-off assertions on a single call |
| Risk | Fake can go stale versus the real API | Over-mocking hides bugs a real object would catch |
A fake is worth the extra effort when you need the same simulated client across dozens of tests, or when you need to simulate multi-step behavior (for example, returning a tool call on the first call and a final answer on the second). Mock/MagicMock is worth its convenience for smaller, more localized tests, especially ones focused on verifying a single interaction. Many real test suites use both: a shared fake client for broad coverage, and targeted MagicMock usage for one-off edge cases.
Common Mistakes
- Patching the wrong import path.
patch("openai.OpenAI")only works if the code under test looks upOpenAIthrough theopenaimodule at call time; if it was imported withfrom openai import OpenAI, the name to patch is the one inside the consuming module. - Over-mocking until the test verifies nothing real. A
MagicMock()accepts any attribute access without complaint, so a typo likefake_client.repsonses.createsilently returns another mock instead of failing — always assert on the specific calls and values that matter, not just that "something" was returned. - Forgetting that
MagicMockaccepts wrong arguments silently. Calling a mocked method with the wrong keyword arguments does not raise an error the way a real client library or a well-built fake with a fixed signature would, so a test can pass even though the real call would fail — configuring fakes withspec=RealClass(viaMagicMock(spec=OpenAI)) constrains the mock to only the real object's actual attributes, catching this category of mistake.
Best Practices
- Prefer dependency injection with a hand-written fake for anything tested repeatedly, and reserve
patch/monkeypatchfor code you cannot easily refactor to accept an injected client. - Use
spec=(orspec_set=) when creating aMagicMockthat stands in for a real class, so the mock raises anAttributeErroron typos or nonexistent methods instead of silently succeeding. - Always assert on both the return value and the call arguments when the correctness of the request matters, not just the final output your function produces.