This is how FastAPI and Starlette test clients work, and it makes end-to-end tests fast and network-free.
A transport decides where requests go. Besides the network and mock transports, HTTPX ships two that call a Python web application in the same process:
httpx.ASGITransport(app=asgi_app)for async frameworks (FastAPI, Starlette, Quart, Django ASGI). Use it withAsyncClient.httpx.WSGITransport(app=wsgi_app)for sync frameworks (Flask, Django WSGI). Use it withClient.
The request is converted to an ASGI scope and receive/send messages (or a WSGI environ), the app runs, and its output is turned into an httpx.Response. Everything above the transport, including redirects, cookies, auth and event hooks, works exactly as against a real server, which makes these tests faithful while staying fast.
Since 0.28 you must pass the transport explicitly; the old app= shortcut on clients is gone:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:A base_url is still needed because requests need a host.
Things to know:
ASGITransportdoes not run the app's lifespan events (startup/shutdown). Use a lifespan manager (e.g.asgi-lifespan) or the framework's test client if your app needs them.raise_app_exceptions=True(the default) re-raises exceptions from the app instead of converting them to 500 responses, which gives better test failures.- Starlette's
TestClientis itself built on HTTPX with a sync wrapper around the ASGI app.
Example
import asyncio
import json
import httpx
async def asgi_app(scope, receive, send): # a minimal ASGI app
body = json.dumps({"path": scope["path"], "method": scope["method"]}).encode()
await send({"type": "http.response.start", "status": 200,
"headers": [(b"content-type", b"application/json")]})
await send({"type": "http.response.body", "body": body})
def wsgi_app(environ, start_response): # a minimal WSGI app
start_response("200 OK", [("Content-Type", "text/plain")])
return [f"hello from {environ['PATH_INFO']}".encode()]
async def main():
transport = httpx.ASGITransport(app=asgi_app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
print((await c.post("/items")).json()) # {'path': '/items', 'method': 'POST'}
asyncio.run(main())
with httpx.Client(transport=httpx.WSGITransport(app=wsgi_app), base_url="http://test") as c:
print(c.get("/ping").text) # hello from /pingRemember
- ASGITransport and WSGITransport route requests into an app in-process
- Pass them as transport=; the app= shortcut was removed in 0.28
- ASGITransport doesn't run lifespan events
Watch out for
- Expecting startup handlers (database connections) to run when testing through ASGITransport.