Testing ASGI Apps with httpx ASGITransport¶
httpx can call an ASGI application directly through httpx.ASGITransport, without a server or a socket — which makes API tests fast and keeps them in the same event loop as the test. The transport is not a server, though, and three differences decide whether a test means what it appears to. Measured on Python 3.14 with httpx 0.28.1 and FastAPI 0.142: requests through ASGITransport took 0.122 ms each, against 0.365 ms to a real uvicorn server over loopback. Without extra setup, the app's lifespan did not run, and the first request failed with AttributeError: 'State' object has no attribute 'db'; entering app.router.lifespan_context(app) fixed it. A streaming endpoint that sent five chunks 0.2 s apart delivered its first chunk after 1.01 s through the transport — the whole body at once — against 0.01 s from the real server. And an endpoint raising RuntimeError surfaced in the test as the RuntimeError itself by default, and as a 500 response with raise_app_exceptions=False. This guide sets up the transport properly and marks what still needs a real server.
Prerequisites¶
- httpx and an ASGI app (FastAPI or Starlette); pytest with pytest-asyncio or AnyIO.
- Async tests, from testing asyncio code with pytest-asyncio.
- The topic overview, Testing Async Code.
1. Run the lifespan¶
ASGITransport sends HTTP requests to the app but not the lifespan startup and shutdown events, so anything the app creates at startup — pools, clients, caches on app.state — does not exist:
@asynccontextmanager
async def lifespan(app):
app.state.db = await create_pool()
yield
await app.state.db.close()
app = FastAPI(lifespan=lifespan)
Measured: a request to an endpoint using request.app.state.db raised AttributeError: 'State' object has no attribute 'db' in the test. Run the lifespan yourself around the client:
@pytest.fixture
async def client():
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
With that fixture, the same request returned 200. The asgi-lifespan package's LifespanManager does the same for any ASGI app by sending the lifespan events; lifespan_context is the Starlette and FastAPI shortcut. Either way, shutdown runs when the fixture exits, so resource cleanup is exercised by every test.
Verify: a test that depends on startup state passes, and a deliberately broken startup fails the fixture rather than the first request.
2. Decide how app exceptions surface¶
By default the transport re-raises an exception that escapes the app, which gives a full traceback in the test but is not what a client would see:
transport = httpx.ASGITransport(app=app) # RuntimeError raised in the test
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) # 500 response, like a server
Measured with an endpoint that raised RuntimeError("bug"): the default produced RuntimeError: bug in the test; with raise_app_exceptions=False, the response was a 500. Use the default for most tests — an unexpected exception should fail the test loudly — and the second form for tests that assert error responses, such as the mapping in mapping ExceptionGroups to HTTP errors, where the 500 is the behaviour under test.
Verify: tests of error responses use raise_app_exceptions=False; all others use the default.
3. Know what streams look like in-process¶
The transport collects the app's response before returning it, so a streaming endpoint's timing is lost. Measured with an endpoint yielding five chunks 0.2 s apart:
t = time.perf_counter()
async with client.stream("GET", "/stream") as response:
async for chunk in response.aiter_bytes():
first = first or time.perf_counter() - t
Through ASGITransport, the first chunk arrived after 1.01 s, together with the rest; from a real uvicorn server, after 0.01 s, with the remaining chunks following every 0.2 s. Content tests of streaming endpoints — the right chunks in the right order — work in-process; tests of streaming behaviour — time to first byte, back-pressure, a client that disconnects mid-stream — need a real server, as in streaming responses with Starlette and FastAPI.
Verify: streaming timing and disconnect behaviour are tested against a real server; ASGITransport tests assert only content.
4. Set the request details tests rely on¶
The transport fabricates connection details. Measured: request.client.host was 127.0.0.1 and the Host header was test, from the client's base_url. Code that uses the client address — rate limiting, audit logs, trusted-proxy logic — sees the same value for every test, and code that builds absolute URLs uses http://test. Set what matters explicitly:
transport = httpx.ASGITransport(app=app, client=("203.0.113.7", 51234)) # fake client address
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as c:
r = await c.get("/users/1", headers={"X-Forwarded-For": "198.51.100.2"})
Tests of per-client behaviour need distinct client addresses, otherwise every request shares one rate-limit bucket. Tests that depend on HTTPS — secure cookies, redirect schemes — need an https:// base URL so the app sees the scheme.
Verify: tests that exercise per-client logic set the client address, and tests of URL generation use a realistic base URL.
5. Keep a few real-server tests¶
In-process tests are faster — 0.122 ms against 0.365 ms per request here — and share the event loop with the test, which makes fixtures and mocks straightforward. A small set of tests should still run against a real server process, because some behaviour exists only there:
@pytest.fixture(scope="session")
def live_server():
proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app", "--port", "8765"])
wait_until_listening("127.0.0.1", 8765)
yield "http://127.0.0.1:8765"
proc.terminate()
proc.wait(timeout=10)
Use it for streaming timing, WebSocket behaviour, request size limits enforced by the server, graceful shutdown, and anything involving real sockets. Everything else — routing, validation, authentication, business logic — belongs in the fast in-process suite. For running the suites in parallel without port clashes, see running async tests in parallel.
Verify: the real-server suite covers each behaviour listed above, and the in-process suite covers everything else.
Verification¶
ASGI tests with httpx are sound when:
- The lifespan runs around every in-process client.
- Exception surfacing is chosen per test: raised by default, 500 when testing error responses.
- Streaming and socket behaviour is tested against a real server.
- Client address and base URL are set where the app depends on them.
Diagnostic Hook: when API tests pass but the first request against a deployed app fails on missing state, or tests fail with AttributeError on app.state, check whether the test client runs the lifespan. Without it, the startup-created attribute was missing here and the request raised AttributeError.
Pitfalls & edge cases¶
- Skipping the lifespan. Measured:
AttributeErroronapp.state. - Testing stream timing in-process. Measured: first chunk after 1.01 s instead of 0.01 s.
- Expecting a 500 by default. The transport re-raised the
RuntimeError. - One client address for all tests. Per-client logic is never exercised.
Frequently Asked Questions¶
How do I test FastAPI with httpx without starting a server?
Use httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test"), inside app.router.lifespan_context(app) so startup runs. Requests took 0.122 ms each.
Does httpx ASGITransport run FastAPI lifespan events?
No. Without entering the lifespan, startup state was missing and a request raised AttributeError. Use lifespan_context or asgi-lifespan's LifespanManager.
Can I test streaming responses with ASGITransport?
Content, yes; timing, no. Five chunks sent 0.2 s apart all arrived after 1.01 s in-process, against the first after 0.01 s from a real server.
Why does my test raise the app's exception instead of getting a 500?
ASGITransport re-raises app exceptions by default. Pass raise_app_exceptions=False to get the 500 response a server would send.
Related¶
- Testing Async Code — up to the topic overview.
- Testing background and periodic tasks — work an API starts but does not finish.
- Resilience, Cancellation & Error Handling — the section overview.