API Mocking & Responses — Deep Dive

Choosing the right mocking tool

Python’s API mocking ecosystem offers tools at different abstraction levels. The right choice depends on your HTTP client, test complexity, and whether you need to record real responses.

httpx.MockTransport: built-in and powerful

httpx includes a mock transport that intercepts all requests without any external library:

import httpx
import pytest


def user_api_handler(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/users/1" and request.method == "GET":
        return httpx.Response(
            200,
            json={
                "id": 1,
                "email": "alice@example.com",
                "name": "Alice Chen",
                "created_at": "2025-01-15T10:00:00Z",
            },
        )
    if request.url.path == "/users" and request.method == "POST":
        body = request.content.decode()
        import json
        data = json.loads(body)
        return httpx.Response(
            201,
            json={"id": 42, **data},
        )
    if request.url.path == "/users/999":
        return httpx.Response(
            404,
            json={"error": {"message": "User not found", "code": "NOT_FOUND"}},
        )
    return httpx.Response(404)


def test_get_user():
    client = httpx.Client(
        transport=httpx.MockTransport(user_api_handler),
        base_url="https://api.test.com/v1",
    )
    resp = client.get("/users/1")
    assert resp.status_code == 200
    assert resp.json()["email"] == "alice@example.com"


def test_create_user():
    client = httpx.Client(
        transport=httpx.MockTransport(user_api_handler),
        base_url="https://api.test.com/v1",
    )
    resp = client.post("/users", json={"email": "bob@test.com", "name": "Bob"})
    assert resp.status_code == 201
    assert resp.json()["id"] == 42

The handler function receives the full request and can inspect method, URL, headers, and body. This is the most Pythonic approach for httpx-based clients.

respx: declarative httpx mocking

respx provides a higher-level API with route patterns:

import httpx
import respx
import pytest


@respx.mock
def test_list_users():
    route = respx.get("https://api.example.com/users").mock(
        return_value=httpx.Response(
            200,
            json={
                "data": [
                    {"id": 1, "name": "Alice"},
                    {"id": 2, "name": "Bob"},
                ],
                "next_cursor": None,
            },
        )
    )

    client = httpx.Client()
    resp = client.get("https://api.example.com/users")
    assert len(resp.json()["data"]) == 2
    assert route.called


@respx.mock
def test_rate_limit_handling():
    # First call returns 429, second succeeds
    respx.get("https://api.example.com/data").mock(
        side_effect=[
            httpx.Response(
                429,
                headers={"Retry-After": "1"},
                json={"error": "Rate limited"},
            ),
            httpx.Response(
                200,
                json={"result": "success"},
            ),
        ]
    )

    client = httpx.Client()
    # Your retry-enabled client should handle this
    resp = client.get("https://api.example.com/data")
    assert resp.status_code == 429  # First attempt

side_effect with a list returns different responses for sequential calls — perfect for testing retry logic.

responses: the requests ecosystem standard

For codebases using requests:

import responses
import requests


@responses.activate
def test_api_error_handling():
    responses.add(
        responses.GET,
        "https://api.example.com/health",
        json={"status": "degraded"},
        status=503,
    )

    resp = requests.get("https://api.example.com/health")
    assert resp.status_code == 503
    assert resp.json()["status"] == "degraded"


@responses.activate
def test_timeout_simulation():
    responses.add(
        responses.GET,
        "https://api.example.com/slow",
        body=requests.exceptions.Timeout("Connection timed out"),
    )

    with pytest.raises(requests.exceptions.Timeout):
        requests.get("https://api.example.com/slow")

The body parameter accepts exception instances, allowing you to simulate network-level failures — not just HTTP error codes.

VCR.py: record and replay

VCR records real HTTP interactions to YAML “cassettes” and replays them in subsequent test runs:

import vcr
import httpx


@vcr.use_cassette("tests/cassettes/github_user.yaml", record_mode="once")
def test_github_user():
    resp = httpx.get("https://api.github.com/users/octocat")
    assert resp.status_code == 200
    assert resp.json()["login"] == "octocat"

First run: makes a real HTTP call and records the response to github_user.yaml. Subsequent runs: replays from the file with no network call.

VCR strengths:

  • Response fixtures are always realistic (they’re real responses)
  • Easy to update: delete the cassette and re-record
  • Tests document actual API behavior

VCR weaknesses:

  • Cassette files can be large (especially for paginated APIs)
  • Sensitive data in responses must be scrubbed
  • API changes require re-recording

Scrubbing sensitive data:

@vcr.use_cassette(
    "tests/cassettes/api_call.yaml",
    record_mode="once",
    before_record_request=lambda r: setattr(
        r, "headers", {
            k: v for k, v in r.headers.items()
            if k.lower() != "authorization"
        }
    ) or r,
    before_record_response=lambda r: r,
)
def test_authenticated_api():
    ...

Fixture management patterns

JSON fixture files

Store realistic response payloads as JSON files:

import json
from pathlib import Path

FIXTURES_DIR = Path(__file__).parent / "fixtures"


def load_fixture(name: str) -> dict:
    path = FIXTURES_DIR / f"{name}.json"
    return json.loads(path.read_text())


def test_parse_complex_response():
    data = load_fixture("stripe_payment_intent")
    # Test your parsing logic with realistic data
    result = parse_payment(data)
    assert result.amount == 2000
    assert result.currency == "usd"

Fixture factories with faker

For parameterized tests, generate fixtures dynamically:

from dataclasses import dataclass
from typing import Any
import random
import string


def random_string(length: int = 8) -> str:
    return "".join(random.choices(string.ascii_lowercase, k=length))


def make_user_response(
    user_id: int | None = None,
    email: str | None = None,
    name: str | None = None,
) -> dict[str, Any]:
    return {
        "id": user_id or random.randint(1, 10000),
        "email": email or f"{random_string()}@example.com",
        "name": name or f"User {random_string(4).title()}",
        "created_at": "2025-06-15T10:00:00Z",
        "is_active": True,
        "plan": "pro",
    }


def make_error_response(
    status: int = 400,
    message: str = "Bad Request",
    code: str = "INVALID_INPUT",
) -> dict:
    return {
        "error": {
            "message": message,
            "code": code,
            "status": status,
        }
    }

Factories ensure every test gets a complete, realistic response while allowing specific field overrides.

Testing async API clients

Mock async transports for async clients:

import httpx
import pytest


async def async_handler(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/users":
        return httpx.Response(200, json={"data": [], "next_cursor": None})
    return httpx.Response(404)


@pytest.mark.anyio
async def test_async_client():
    transport = httpx.MockTransport(async_handler)
    async with httpx.AsyncClient(
        transport=transport,
        base_url="https://api.test.com",
    ) as client:
        resp = await client.get("/users")
        assert resp.status_code == 200

Contract testing with schema validation

Mocks drift from reality over time. Validate mock responses against the API’s schema:

import jsonschema

USER_SCHEMA = {
    "type": "object",
    "required": ["id", "email", "name", "created_at"],
    "properties": {
        "id": {"type": "integer"},
        "email": {"type": "string", "format": "email"},
        "name": {"type": "string"},
        "created_at": {"type": "string", "format": "date-time"},
    },
}


def validate_fixture(data: dict, schema: dict) -> None:
    jsonschema.validate(instance=data, schema=schema)


def test_user_fixture_matches_schema():
    fixture = make_user_response()
    validate_fixture(fixture, USER_SCHEMA)  # Fails if fixture drifts

Run schema validation in CI against your fixtures. When the real API changes its schema, update both the schema and the fixtures.

Mocking strategies comparison

ToolHTTP ClientLevelBest For
httpx.MockTransporthttpxTransportFull client testing, no deps
respxhttpxInterceptDeclarative route matching
responsesrequestsInterceptrequests ecosystem
VCR.pyBothRecord/replayRealistic fixtures, exploratory
unittest.mockAnyFunctionQuick patches, simple cases

Anti-patterns to avoid

Mock everything: If your test mocks the HTTP client, the JSON parser, and the domain logic, it tests nothing real.

Copy-paste fixtures: Duplicate fixtures across tests diverge over time. Use factories or shared fixture files.

Never update cassettes: VCR cassettes recorded 6 months ago may not reflect the current API. Schedule periodic re-recording.

Exact URL matching only: APIs add query parameters over time. Use pattern matching (respx.get(url__startswith=...)) for resilience.

Ignoring request validation: Your mock should verify that the request was correct — right headers, right body format, right auth token — not just return a canned response.

The one thing to remember: Effective API mocking combines transport-level interception for realistic HTTP behavior, fixture factories for consistent test data, and schema validation to prevent mocks from drifting away from the real API’s contract.

pythontestingapis

See Also

  • Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
  • Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
  • Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
  • Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.
  • Python Beautifulsoup Understand Beautifulsoup through a practical analogy so your Python decisions become faster and clearer.