Starlette routes and middleware tested offline with httpx ASGITransport: working versions (tested 2026-10-15)
A Starlette app with BaseHTTPMiddleware and lifespan state, exercised in-process via httpx ASGITransport without a real server.
passed6 passed · 0 failed2026-10-15 16:05 UTC- Status
- passed
- Last passed
- 2026-10-15 16:05 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 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("starlette-httpx-asgi-client") (install), or see them on GitHub.
starlette==1.8.0 httpx==0.28.1 pytest==9.1.1 pytest-asyncio==1.4.0
from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Route
class TraceMiddleware(BaseHTTPMiddleware):
def __init__(self, app, name):
super().__init__(app)
self.name = name
async def dispatch(self, request, call_next):
request.app.state.trace.append(f"{self.name}:in")
response = await call_next(request)
request.app.state.trace.append(f"{self.name}:out")
return response
@asynccontextmanager
async def lifespan(app):
app.state.trace = []
app.state.items = {1: "one", 2: "two"}
try:
yield
finally:
app.state.trace.clear()
del app.state.items
async def get_item(request):
item_id = request.path_params["item_id"]
if item_id not in request.app.state.items:
return JSONResponse({"detail": "not found"}, status_code=404)
return JSONResponse({"id": item_id, "name": request.app.state.items[item_id]})
async def crash(request):
raise ValueError("boom")
def create_app():
return Starlette(
routes=[Route("/items/{item_id:int}", get_item), Route("/crash", crash)],
middleware=[
Middleware(TraceMiddleware, "outer"),
Middleware(TraceMiddleware, "inner"),
],
lifespan=lifespan,
)
import httpx
import pytest
from app import create_app
@pytest.fixture
async def app():
application = create_app()
async with application.router.lifespan_context(application):
yield application
@pytest.fixture
async def client(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
async def test_route_returns_json(client):
response = await client.get("/items/1")
assert response.status_code == 200
assert response.json() == {"id": 1, "name": "one"}
async def test_unknown_item_is_404(client):
response = await client.get("/items/9")
assert response.status_code == 404
assert response.json() == {"detail": "not found"}
async def test_middleware_runs_outermost_first(app, client):
app.state.trace.clear()
await client.get("/items/1")
assert app.state.trace == ["outer:in", "inner:in", "inner:out", "outer:out"]
async def test_transport_does_not_run_lifespan():
bare = create_app()
transport = httpx.ASGITransport(app=bare)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
with pytest.raises(AttributeError):
await client.get("/items/1")
async with bare.router.lifespan_context(bare):
assert (await client.get("/items/1")).status_code == 200
async def test_unhandled_exception_propagates(client):
with pytest.raises(ValueError):
await client.get("/crash")
async def test_raise_app_exceptions_false_returns_500():
app = create_app()
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/crash")
assert response.status_code == 500
[pytest] pythonpath = . asyncio_mode = auto
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 config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI endpoints rate limited with slowapi, tested with TestClient
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.
FastAPI Jinja2 full pages vs HTMX partials via HX-Request
Serve a full HTML page to normal browsers and a bare HTML fragment to HTMX requests, chosen by the HX-Request header, with Vary set for caches. Tested offline with the sync TestClient.