backend / testing / pytest / 01_mocks_external_apis.md

Unit Tests with Mocks for External APIs

3 interview angles 4 min read source

Unit Tests with Mocks for External APIs

Why mock external APIs in unit tests?

  • Speed: No real HTTP calls; tests run fast.
  • Stability: Tests don’t depend on network or third-party availability or rate limits.
  • Control: You can simulate success, failure, timeouts, and edge cases (e.g. 500, empty response, malformed JSON).
  • Isolation: You test your logic (parsing, retries, fallbacks) against a fixed “contract,” not the real API’s current behavior.
  • No side effects: No real charges, no real data created or deleted in external systems.

So you mock to get fast, reliable, isolated tests that cover your code paths.


How do you mock external API calls in Python?

  • unittest.mock.patch: Patch the function or client that performs the HTTP call (e.g. requests.get, or your httpx.Client.get). In the test, set return_value or side_effect (e.g. a Response-like object or an exception).
  • responses (for requests): Register expected URLs and response bodies; the library intercepts matching requests and returns them. Good for request/response tests without patching your code.
  • httpx.MockTransport (for httpx): Define a custom transport that returns fixed responses for given URLs or patterns. No real network; full control over status, headers, body.
  • Dependency injection: Your code receives an “API client” (interface) from the outside. In tests you inject a fake or mock that returns canned data or raises; in production you inject the real client. No patching of modules; cleaner and easier to maintain.

Prefer DI + fake/mock over patching implementation details when you can.


What is the difference between a mock and a fake (test double)?

  • Mock: A test double that records how it was called (args, kwargs, call count) and can assert on that. You configure return values or side effects. Use when you care about “was this called with these arguments?” or “was it called once?”
  • Fake: A real but simplified implementation (e.g. in-memory storage, or a small HTTP server that returns fixed responses). It doesn’t record calls; it just behaves. Use when you want to test behavior against a realistic but controlled dependency.
  • Stub: Only provides canned answers; no verification of calls.
  • Spy: Wraps a real object and records calls; you can still use real behavior and assert on invocations.

In practice: use a mock when you only need to control the response and optionally assert calls; use a fake when you want more realistic interaction (e.g. multiple requests, state).


How do you structure unit tests that call code which uses an external API?

  • Inject the client: The code under test receives an API client (or a “fetcher” callable) as a dependency. In the test, inject a mock or fake that returns the responses you need.
  • One test per scenario: Test “API returns 200” → your code returns parsed result. Test “API returns 500” → your code retries or returns error/fallback. Test “API times out” → your code handles timeout. Each test sets up the mock/fake for that case.
  • Assert both your output and (if using a mock) calls: Check that your function returns the right value or raises the right error, and optionally that the client was called the expected number of times with the expected arguments (e.g. after retries).
  • Don’t test the external API: Test your parsing, retry logic, error handling, and fallbacks. The mock only provides inputs; you assert on your code’s behavior.

Example: mocking an HTTP client in a unit test (concept)

Conceptually:

  • Production: Your service uses httpx.Client (or a wrapper) to call GET https://api.example.com/data.
  • Test: Create a mock (e.g. Mock() or AsyncMock() for async). Configure mock_client.get.return_value = httpx.Response(200, json={"id": 1, "name": "Test"}). Inject mock_client into your service. Call the service method. Assert that the service returns the parsed result (e.g. assert result.name == "Test") and optionally mock_client.get.assert_called_once_with("https://api.example.com/data").
  • Error case: Set mock_client.get.side_effect = httpx.TimeoutException(). Call the service. Assert that it raises or returns your fallback/error as designed.

With dependency injection, the service’s constructor takes client: HttpClient; in the test you pass the mock; in production you pass the real client. No patching of module attributes.


What should you test when the code uses external APIs?

  • Success path: Mock returns 200 and valid body; assert that your code returns the expected domain object or value.
  • Error responses: Mock returns 4xx/5xx or raises; assert that your code raises a specific exception, returns an error result, or triggers a fallback.
  • Retries: Mock fails N times then succeeds; assert that your code eventually returns success and that the client was called N+1 times (or use a spy/call list).
  • Timeouts: Mock raises timeout; assert that your code handles it (retry, fallback, or clear error).
  • Parsing: Mock returns malformed or unexpected JSON; assert that your code raises or returns a structured validation error.
  • Circuit breaker (if applicable): After several failed calls, assert that the next call doesn’t hit the client (fast fail or fallback).

Cover the behavior of your code; the mock only provides the external API’s “answers.”

Interview angle

  • “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. Hand-written fixtures encode what you assume the API returns; recorded ones include the nulls and unexpected fields that actually break parsing.
  • “What does mocking not protect you from?” - the provider changing their contract. That’s what contract tests address: replay saved responses against the live provider on a schedule and alert on drift.