Isolation & Contracts

Mocking httpx Async Clients with respx

httpx is the default async HTTP client in modern Python services, and patching it the obvious way — replacing client.get with an AsyncMock — produces tests that pass while proving nothing about URL construction, headers, serialization or timeouts. respx intercepts one layer lower, at the transport, so the client under test builds a real request that a route then answers.

Prerequisites

  • httpx 0.24+, respx 0.20+, and pytest-asyncio 0.23+ (or anyio) for the async tests below.
  • The interception layers compared in mocking network and HTTP calls.

Solution

Activate the router, declare routes, and let the client run for real up to the transport boundary.

Python
import httpx
import pytest
import respx

@respx.mock
@pytest.mark.asyncio
async def test_fetch_orders_builds_the_right_request():
    route = respx.get(
        "https://api.example.com/orders",
        params={"status": "open"},                 # query is part of the match
    ).mock(return_value=httpx.Response(200, json={"orders": []}))

    async with httpx.AsyncClient(base_url="https://api.example.com") as client:
        resp = await client.get("/orders", params={"status": "open"},
                                headers={"Authorization": "Bearer t"})

    assert resp.status_code == 200
    assert route.called
    # The recorded request is a real httpx.Request: assert on what was sent.
    sent = route.calls.last.request
    assert sent.headers["Authorization"] == "Bearer t"
    assert sent.url.params["status"] == "open"

Every route object records its own calls, which is what makes the assertions specific. route.called, route.call_count and route.calls[i].request cover nearly every claim a test needs to make, and because the request is a genuine httpx.Request, base-URL joining, parameter encoding and header defaults are all exercised rather than skipped.

Failure paths are one argument away, which is the main reason to use a transport-level mock at all:

Python
import httpx, respx, pytest

@respx.mock
@pytest.mark.asyncio
async def test_retries_once_on_timeout():
    route = respx.get("https://api.example.com/orders")
    route.side_effect = [httpx.ConnectTimeout("timed out"),
                         httpx.Response(200, json={"orders": []})]

    async with httpx.AsyncClient(base_url="https://api.example.com") as client:
        resp = await fetch_with_retry(client, "/orders", attempts=2)

    assert resp.status_code == 200
    assert route.call_count == 2            # the retry really happened

A list side_effect is consumed in order, exactly as it is on a unittest.mock double, so a scripted sequence of failure-then-success needs no custom callable.

What each mocking layer still exercises A stack of four httpx mocking approaches ordered by depth: replacing the client method, replacing the client object, respx at the transport layer, and a real local server, showing what code each approach still runs. What each mocking layer still exercises AsyncMock on client.get nothing: no URL, no headers, no serialization a fake client object your call sites only respx at the transport URL building, params, headers, retries a real local server everything, including the socket The top row is the one that produces green tests against a broken URL.
Transport interception is the sweet spot: no network, but every layer of the client that a bug could hide in still runs.

Why this works

httpx routes every request through a transport object — HTTPTransport or AsyncHTTPTransport — that both the sync and async clients share as their final step. respx installs a mock transport in its place, so the client performs request construction, base-URL resolution, parameter encoding, header defaults, cookie handling and event hooks exactly as in production, and the substituted transport answers instead of opening a connection. Because the substitution happens at that single seam, the same route definitions work for both client types, and nothing in the code under test needs to know it is being tested.

Where respx intercepts an httpx request A sequence diagram with three lanes: the code under test, the httpx client, and the respx mock transport. The code calls get, the client builds a full Request object with resolved URL and headers, the mock transport matches it against declared routes and returns the declared response, and the client wraps it as a normal Response. Where respx intercepts an httpx request code under test httpx client respx transport client.get("/orders") build Request send(request) declared Response Response object The recorded Request is the same object the network would have received.
Everything left of the transport is real code — which is precisely the code a URL or header bug lives in.

Routing rules that keep tests honest

A route that matches too loosely is a deleted assertion. respx gives four matchers worth using deliberately.

Method and URL are the base match, and the URL can be a string, a pattern, or a host plus path. Prefer the most specific form the test can support: respx.get("https://api.example.com/orders/7") fails when the code builds the wrong id, where a regex over /orders/.* does not.

Query parameters with params= match independently of ordering, which matters because dict ordering is an implementation detail of the calling code.

Body matchersjson__eq, data__contains — pin what was sent. For anything beyond a simple equality, prefer asserting on route.calls.last.request.content afterwards, which produces a readable diff on failure.

Headers with headers= check only the named headers, so authentication can be asserted without freezing every default the client adds.

Python
route = respx.post(
    "https://api.example.com/charges",
    json__eq={"amount": 500, "currency": "eur"},     # exact body
    headers={"Idempotency-Key": "order-7"},          # only this header
)

Two global settings shape the default strictness, and both are worth setting explicitly in a shared fixture. assert_all_mocked=True (the default) makes an unmatched request an error rather than a passthrough, and assert_all_called=True (also the default inside the decorator) fails the test when a declared route was never used — which catches dead code paths and stale stubs left behind by a refactor.

Python
import pytest, respx

@pytest.fixture
def api_mock():
    with respx.mock(base_url="https://api.example.com",
                    assert_all_called=True) as router:
        yield router                    # routes declared per test, all must fire

Migrating from patched clients

Most codebases arrive here with tests that patch the client object. Converting them is mechanical and each conversion strengthens the test.

Start by deleting the patch and adding @respx.mock. The test will fail with AllMockedAssertionError naming the exact request the code made — which is immediately useful, because that request is the contract the old test never checked. Turn it into a route, and the test passes again, now asserting the URL and method for the first time.

Then replace mock_client.get.assert_called_once_with(...) with assertions on route.calls.last.request. The old assertion checked the arguments your code passed to the client; the new one checks the request the client actually built, which is a stronger claim and catches base-URL and encoding bugs that the argument-level assertion cannot see.

Finally, delete any fake response objects the old test constructed by hand. httpx.Response(200, json=...) builds a real one, so downstream code that calls .json(), .raise_for_status() or reads .headers runs against the genuine implementation instead of a Mock that returns whatever it was told to.

Edge cases and failure modes

  • Event hooks and retries run for real. A client configured with transport=httpx.AsyncHTTPTransport(retries=3) retries against the mock too, which is usually desirable but doubles the expected call_count.
  • Streaming responses need httpx.Response(200, stream=...). Returning a plain body and then reading .aiter_bytes() works, but tests of chunked handling should build the stream explicitly.
  • base_url on the router and on the client compose. Declaring a route with a full URL while the router also has a base_url produces a match failure that reads confusingly; pick one place to put the host.
  • Unmatched requests raise inside the client, so a try/except httpx.HTTPError in the code under test can swallow the mocking error. Assert route.called explicitly rather than relying on the exception surfacing.
  • respx and pytest-asyncio fixture scopes interact. A router created in a session-scoped fixture outlives the event loop that tests run on; keep the router function-scoped, as discussed in how to scope pytest fixtures for async tests.
  • Not every library uses httpx's transport. A vendor SDK built on aiohttp or raw urllib3 is unaffected by respx and needs its own interception layer.

Sharing routes across a test suite

Once more than a handful of tests talk to the same API, the route definitions want to live in one place — otherwise every test re-declares the happy path and a change to the API contract means editing thirty files.

A fixture that installs the baseline routes and yields the router gives each test a working default it can override:

Python
import httpx, pytest, respx

@pytest.fixture
def api(respx_mock):                      # respx ships a pytest fixture
    respx_mock.get("/health").mock(return_value=httpx.Response(200))
    respx_mock.get("/orders").mock(return_value=httpx.Response(200, json={"orders": []}))
    return respx_mock

def test_specific_case(api):
    # Re-declaring a route replaces the earlier one for this test only.
    api.get("/orders").mock(return_value=httpx.Response(503))
    ...

Two conventions keep that from becoming its own maintenance problem. Keep the shared fixture to responses that are genuinely uninteresting — health checks, token endpoints, anything every test needs and no test asserts on. And keep assert_all_called off for the shared routes, since not every test will exercise them; enable it per test for the routes that test declares itself.

The larger version of this problem — keeping the mocked responses faithful to the real API — is what contract testing and recorded cassettes address, and it is worth deciding early which of the two the project will rely on rather than discovering the drift in production.

What to assert, and where it lives A table of four things a test may want to assert about an HTTP interaction - that the call happened, how many times, what was sent, and that nothing else was called - naming the respx attribute for each. What to assert, and where it lives Criterion Assert with Catches the call happened route.called wrong URL or method how many times route.call_count missing or extra retries what was sent route.calls.last.request payload and header bugs nothing else assert_all_mocked unexpected requests
The last row is the one teams forget, and it is the one that turns an unexpected request into a failure rather than a passthrough.

Frequently Asked Questions

Does respx work with both sync and async httpx clients? Yes. respx patches the httpx transport layer, which both client types share, so the same route definitions serve AsyncClient and Client without any change to the test.

What happens to a request that matches no route? By default respx raises AllMockedAssertionError, so an unexpected request fails the test rather than escaping to the network. Pass assert_all_mocked=False to let unmatched requests pass through instead.

How do I assert a request was made with a specific body? Every route records its calls, so route.calls.last.request gives the httpx.Request object with its content, headers and URL. Assert on that rather than trying to express everything in the route matcher.

Can respx simulate timeouts and connection errors? Yes. A route can be given side_effect=httpx.ConnectTimeout or any exception class, which raises when the route matches — the standard way to test retry and timeout handling without waiting.

← Back to Mocking Network and HTTP Calls