Backend / Testing / pytest / 01_mocks_external_apis.md

Mocking external APIs

Updated 5 interview angles 3 min read source
On this page7
  1. Mock at the transport, not at your client
  2. Fixtures come from real responses
  3. The cases worth writing
  4. Mock, fake, stub, spy
  5. What mocking cannot protect you from
  6. Related
  7. Interview angle

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

python
# 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.

python
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.called

Now 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.

python
# 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:

python
@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"
python
@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.

Interview angle 5

  • “How do you test code that calls an external API?” - intercept at the HTTP layer with responses or respx rather 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.