Calling ASGI and WSGI apps directly

HTTPX · Expert · 12 min

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 with AsyncClient.
  • httpx.WSGITransport(app=wsgi_app) for sync frameworks (Flask, Django WSGI). Use it with Client.

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:

  • ASGITransport does 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 TestClient is 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 /ping

Remember

  • 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.

Check yourself

  1. Which client type should you use with httpx.ASGITransport?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.python-httpx.org/advanced/transports/#asgi-transport