Skip to content

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

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.

ASGITransport compared with a real server A grid of 5 rows by 3 columns. ASGITransport compared with a real server aspect ASGITransport uvicorn over TCP time per request 0.122 ms 0.365 ms lifespan not run unless you enter it run by the server streaming: first of 5 chunks, 0.2 s apart after 1.01 s (buffered) after 0.01 s unhandled exception raised in the test (default) or 500 500 request.client / Host header 127.0.0.1 / test real peer / real host httpx 0.28.1, FastAPI 0.142.2, Python 3.14.

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.

Time to first chunk of a streamed response 2 horizontal bars comparing ASGITransport (in-process) with the others. Time to first chunk of a streamed response ASGITransport (in-process) 1.01 s uvicorn over TCP 0.01 s In-process, the whole body arrives at once.

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.

Setting up in-process API tests A flow of 5 stages. Setting up in-process API tests Fixture enter lifespan_context(app) Client ASGITransport, base_url, client addr Errors raise by default; 500s when testing them Streams content only in-process Real server timing, sockets, shutdown 0.122 ms per request in-process; a real server for what only it can show.

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: AttributeError on app.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.