Mocking external APIs
The decision that matters is where you cut. Mock too close to your own code and you test the mock; mock at the HTTP boundary and you test everything you own, including the parts that break.
Mock at the transport, not at your client
# Weak: this asserts your mock was called.
client = Mock()
client.get.return_value = {"id": 1}
service = Service(client)That test passes if your URL is wrong, your timeout is missing, your error mapping is broken, and your retry never fires. None of it ran.
import respx, httpx
@respx.mock
def test_fetches_user():
route = respx.get("https://api.example.com/users/1").mock(
return_value=httpx.Response(200, json={"name": "Ada"})
)
assert Service().user(1).name == "Ada"
assert route.calledNow your real client runs. URL construction, headers, JSON parsing, status handling and the model validation all execute — only the socket is replaced.
| Library | For |
|---|---|
respx |
httpx |
responses |
requests |
aioresponses |
aiohttp |
unittest.mock |
non-HTTP boundaries |
Fixtures come from real responses
Hand-written fixtures encode what you assume the API returns. Record one real response per endpoint and the fixture contains the nulls, the extra fields and the odd date format that actually break parsing.
# Captured once, from the real API.
@pytest.fixture
def user_200():
return json.loads((FIXTURES / "user_200.json").read_text())Gotcha: scrub the recording before committing it. Real responses carry tokens, emails and account ids, and a fixture file is as public as the repo.
The cases worth writing
The success path is the one people write and the least likely to break. These are the ones that pay:
@respx.mock
def test_retries_then_succeeds():
respx.get(URL).mock(side_effect=[
httpx.Response(503),
httpx.Response(503),
httpx.Response(200, json={"name": "Ada"}),
])
assert Service().user(1).name == "Ada"@respx.mock
def test_timeout_falls_back():
respx.get(URL).mock(side_effect=httpx.TimeoutException("x"))
assert Service().user(1) is CACHED_DEFAULT| Case | Asserts |
|---|---|
| 4xx | mapped to your exception type |
| 5xx | retried, then surfaced |
| Timeout | fallback or clear error |
| Malformed body | validation error, not a crash |
| Extra fields | ignored, not rejected |
The malformed-body case is the one that catches real regressions, because a provider adding a field or changing a type does not announce itself.
Mock, fake, stub, spy
| Double | Behaviour | Use when |
|---|---|---|
| Stub | returns canned data | you need an input |
| Mock | records calls, asserts | the call itself matters |
| Fake | working simplified impl | multiple calls, state |
| Spy | wraps real, records | you want real behaviour too |
In practice you need two of them: a stub at the transport for most tests, and a fake when the interaction is stateful — an in-memory implementation of the provider that pagination or idempotency tests can actually exercise.
What mocking cannot protect you from
The provider changing their contract. Every test above passes forever against a fixture recorded in 2024.
The answer is a contract test on a schedule, not in the PR pipeline: replay your saved requests against the live API nightly and diff the response shape. It fails when they change, which is the only signal that exists.
Related
Interview angle 5
- “How do you test code that calls an external API?” - intercept at the HTTP layer with
responsesorrespxrather than mocking your own client class. That way you test your client’s real behaviour - URL construction, error mapping, retries - instead of asserting that a mock was called. - “Where do the fixtures come from?” - record one real response per endpoint, then scrub it. Hand-written fixtures encode what you assume the API returns; recorded ones include the nulls and unexpected fields that actually break parsing.
- “Which cases would you write?” - the failures, since the success path rarely regresses: 4xx mapped to your exception, 5xx retried then surfaced, timeout falling back, and a malformed body producing a validation error rather than a crash.
- “Mock or fake?” - a stub at the transport for most tests. A fake — a working in-memory implementation — when the interaction is stateful and you need to exercise pagination or idempotency across several calls.
- “What does mocking not protect you from?” - the provider changing their contract. That’s what contract tests address: replay saved requests against the live provider on a schedule and alert on drift.