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.
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:
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.
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.
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 matchers — json__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.
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.
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 expectedcall_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_urlon the router and on the client compose. Declaring a route with a full URL while the router also has abase_urlproduces a match failure that reads confusingly; pick one place to put the host.- Unmatched requests raise inside the client, so a
try/except httpx.HTTPErrorin the code under test can swallow the mocking error. Assertroute.calledexplicitly rather than relying on the exception surfacing. respxandpytest-asynciofixture 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
aiohttpor rawurllib3is 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:
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.
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.
Related
- Mocking network and HTTP calls — the interception layers and when each is right.
- Mocking requests with the responses library — the same approach for the
requestsstack. - Recording and replaying HTTP with VCR.py — when the response bodies should come from the real API.
- Mock vs MagicMock vs AsyncMock — when to use each — the double you no longer need once the transport is mocked.
← Back to Mocking Network and HTTP Calls