Quart async routes tested offline with httpx ASGITransport: working versions (tested 2026-10-15)
A Quart app with async JSON bodies and an injected httpx client, exercised fully in-process via httpx.ASGITransport and httpx.MockTransport.
passed6 passed · 0 failed2026-10-15 18:10 UTC- Status
- passed
- Last passed
- 2026-10-15 18:10 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38783276983
The code
Exactly the files that passed. Your agent can pull them with get_recipe("quart-httpx-asgi-client") (install), or see them on GitHub.
quart==0.23.1 httpx==0.28.1 pytest==9.1.1
from __future__ import annotations
import httpx
from quart import Quart, abort, request
UPSTREAM_URL = "https://rates.example.com"
def create_app(upstream: httpx.AsyncClient | None = None) -> Quart:
app = Quart(__name__)
app.extensions["upstream"] = upstream
# These lifecycle hooks only run when a real server drives the ASGI lifespan,
# which httpx.ASGITransport does not do. Inject `upstream` in tests instead.
@app.before_serving
async def open_upstream() -> None:
if app.extensions["upstream"] is None:
app.extensions["upstream"] = httpx.AsyncClient(base_url=UPSTREAM_URL)
@app.after_serving
async def close_upstream() -> None:
client = app.extensions["upstream"]
if client is not None:
await client.aclose()
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/echo")
async def echo() -> dict[str, object]:
# Quart request bodies are async, unlike Flask's synchronous request.json.
payload = await request.get_json()
return {"received": payload}
@app.get("/convert")
async def convert() -> dict[str, object]:
try:
amount = float(request.args.get("amount", "1"))
except ValueError:
abort(400, "amount must be numeric")
currency = request.args.get("to", "EUR").upper()
client = app.extensions["upstream"]
if client is None:
abort(503, "upstream client not started")
response = await client.get("/latest", params={"base": "USD"})
response.raise_for_status()
rate = response.json()["rates"][currency]
return {"amount": amount, "currency": currency, "converted": amount * rate}
@app.get("/boom")
async def boom() -> dict[str, str]:
raise RuntimeError("deliberate failure")
return app
import httpx
import pytest
from app import create_app
def asgi(app, **transport_kwargs) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, **transport_kwargs)
return httpx.AsyncClient(transport=transport, base_url="http://testserver")
def fake_upstream(rates: dict[str, float], status_code: int = 200) -> httpx.AsyncClient:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(status_code, json={"base": "USD", "rates": rates})
return httpx.AsyncClient(
transport=httpx.MockTransport(handler), base_url="https://rates.example.com"
)
@pytest.mark.anyio
async def test_health_roundtrip():
async with asgi(create_app()) as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
@pytest.mark.anyio
async def test_json_body_is_awaited():
async with asgi(create_app()) as client:
response = await client.post("/echo", json={"numbers": [1, 2, 3]})
assert response.json()["received"] == {"numbers": [1, 2, 3]}
@pytest.mark.anyio
async def test_convert_uses_mocked_upstream():
app = create_app(upstream=fake_upstream({"EUR": 0.9}))
async with asgi(app) as client:
response = await client.get("/convert", params={"amount": "10", "to": "eur"})
assert response.json() == {"amount": 10.0, "currency": "EUR", "converted": 9.0}
@pytest.mark.anyio
async def test_lifespan_hooks_are_skipped_by_asgi_transport():
# No injected upstream, so before_serving would have created one had it run.
async with asgi(create_app()) as client:
response = await client.get("/convert", params={"amount": "10", "to": "EUR"})
assert response.status_code == 503
@pytest.mark.anyio
async def test_abort_becomes_a_response():
app = create_app(upstream=fake_upstream({}))
async with asgi(app) as client:
response = await client.get("/convert", params={"amount": "abc"})
assert response.status_code == 400
# abort() renders Quart's default HTML error page, not plain or JSON text.
assert response.headers["content-type"].startswith("text/html")
assert "amount must be numeric" in response.text
@pytest.mark.anyio
async def test_raise_app_exceptions_flag():
app = create_app()
app.config["PROPAGATE_EXCEPTIONS"] = True
# Default ASGITransport re-raises, and Quart reports view errors wrapped
# in an ExceptionGroup (from its internal task group), not as RuntimeError.
with pytest.raises(ExceptionGroup) as excinfo:
async with asgi(app) as client:
await client.get("/boom")
assert isinstance(excinfo.value.exceptions[0], RuntimeError)
# ... set raise_app_exceptions=False to assert on the 500 response instead.
async with asgi(app, raise_app_exceptions=False) as client:
response = await client.get("/boom")
assert response.status_code == 500
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI dependency_overrides tested offline with httpx ASGITransport
Override FastAPI dependencies (including nested ones) and drive the app in-process with httpx.ASGITransport, no network and no running server.
FastAPI metrics with prometheus_client scraped as text
Instrument FastAPI with prometheus_client using a per-app registry, label by route template to bound cardinality, and scrape the text exposition format offline through httpx.ASGITransport.
Starlette multipart form uploads parsed with python-multipart, tested offline
Parse multipart text fields and file uploads in Starlette with python-multipart, then exercise the app fully in-process through httpx.ASGITransport with no network.
authlib OAuth2 client credentials flow + httpx mocked by respx
An async authlib OAuth2 client that fetches, caches and auto-refreshes client_credentials tokens over httpx, tested fully offline with respx route mocks.