Testing with TestClient
stario.testing.TestClient runs HTTP handlers in-process with no listening socket—the same HTTP stack as stario serve, without binding a port. Always open the client with async with TestClient(...) so shutdown runs (disconnect, drain_tasks).
This page is copy-paste recipes; the Testing reference is canonical for full argument lists and pytest-asyncio setup.
Basic usage
Pass the same bootstrap callable you use with stario serve (see Testing — pattern 1) so routes, mounts, and url_for match production. Use TestClient(app_factory=lambda: build_app()) when each test needs a fresh App (see Testing — API).
from stario.testing import TestClientfrom myapp.app import bootstrap async def test_home(): async with TestClient(bootstrap) as client: r = await client.get("/") assert r.status_code == 200You can also pass a ready-made App for small, self-contained tests. With a bootstrap factory, client.app is the live app only after you enter the context manager.
Async test setup (pytest-asyncio)
Use pytest with pytest-asyncio. Set asyncio_mode = auto under [tool.pytest.ini_options] in your app’s pyproject.toml so ordinary async def tests run without extra marks. In strict mode, use @pytest_asyncio.fixture for async fixtures and follow pytest-asyncio—see also Testing.
Examples
The patterns below mirror the Testing reference. Treat them as starters; plug in your real paths and assertions.
Integration test: real bootstrap, query params, JSON body
Use this when you want the same composition root as stario serve—routes, mounts, middleware, and url_for registrations all match production.
from stario.testing import TestClientfrom myapp.app import bootstrap async def test_items_list(): async with TestClient(bootstrap) as client: r = await client.get("/items", params={"page": "2"}) assert r.status_code == 200 data = r.json() assert "items" in data async def test_create_item(): async with TestClient(bootstrap) as client: r = await client.post("/items", json={"name": "cup"}) assert r.status_code == 201 assert r.json()["name"] == "cup"Request keywords (params, json, …) and TestResponse fields: Testing pattern 1 and the TestClient / TestResponse API on that page.
Small App in the test file
When you only need one route or a narrow behavior, build an App, register handlers, and pass app to TestClient. No separate module import—fast feedback while refactoring a handler.
import json import stario.responses as responsesfrom stario import App, Context, Writerfrom stario.testing import TestClient async def test_echo_json(): app = App() async def echo(c: Context, w: Writer) -> None: payload = json.loads(await c.req.body()) responses.json(w, {"you_sent": payload}) app.post("/echo", echo, name="echo") async with TestClient(app) as client: r = await client.post("/echo", json={"x": 1}) assert r.status_code == 200 assert r.json()["you_sent"]["x"] == 1Same arrangement as Testing — pattern 1 (inline App snippet), with JSON echo so r.json() has something to read.
Streaming (chunks and SSE)
Use async with client.stream(...) when the handler keeps the connection open (chunked body, SSE, live logs). Read with await r.body(), async for over r.iter_bytes(), or r.iter_events() for text/event-stream. Leaving the async with disconnects that request (w.disconnected becomes true). stream does not follow redirects—use buffered get if you need redirect following. Full behavior: Testing — pattern 2.
Chunked bytes — join pieces the handler wrote with separate w.write calls:
from stario import App, Context, Writerfrom stario.testing import TestClient async def test_stream_reassembles_chunks(): app = App() async def chunked(c: Context, w: Writer) -> None: w.write_headers(200) w.write(b"hel") w.write(b"lo") w.end() app.get("/chunked", chunked) async with TestClient(app) as client: async with client.stream( "GET", "/chunked", headers={"Accept-Encoding": "identity"} ) as r: assert r.status_code == 200 parts = [chunk async for chunk in r.iter_bytes()] assert b"".join(parts) == b"hello"SSE-shaped responses — iter_events() yields dicts with optional keys (event, id, data may be absent depending on the frame):
from stario import App, Context, Writerfrom stario.testing import TestClient async def test_stream_reads_sse_events(): app = App() async def sse(c: Context, w: Writer) -> None: w.headers.set("content-type", "text/event-stream") w.write_headers(200) w.write(b"event: ping\ndata: hello\n\n") w.end() app.get("/events", sse) async with TestClient(app) as client: async with client.stream( "GET", "/events", headers={"Accept-Encoding": "identity"} ) as r: events = [e async for e in r.iter_events()] assert events == [{"event": "ping", "data": "hello"}]Use TestClient(bootstrap) instead of TestClient(app) when the streaming route lives in your real app—the async with client.stream block is the same.
Named routes and client.app
After async with TestClient(bootstrap), client.app is the live app: use client.app.url_for("route_name") to build the path and assert you are exercising the route you think you are (Runtime — Reverse URLs).
from stario.testing import TestClientfrom myapp.app import bootstrap async def test_home_via_url_for(): async with TestClient(bootstrap) as client: path = client.app.url_for("home") r = await client.get(path) assert r.status_code == 200If the app mounts static assets with name="static", client.app.url_for("static:css/app.css") resolves to the fingerprinted URL in tests the same way as in handlers.
Tracer snapshot (optional)
To assert on finished spans for a request, use r.span_id with client.tracer (see Testing — pattern 1):
from stario.testing import TestClientfrom myapp.app import bootstrap async def test_request_traced(): async with TestClient(bootstrap) as client: r = await client.get("/report/7") t = client.tracer assert t.has_attribute(r.span_id, "request.path", "/report/7")Fixture pattern
import pytestfrom stario.testing import TestClientfrom myapp.app import bootstrap @pytest.fixtureasync def client(): async with TestClient(bootstrap) as c: yield c async def test_page(client): assert (await client.get("/about")).status_code == 200What to assert
r.text,r.json(),r.status_code,r.headers,r.cookiesr.span_idwithclient.tracerfor finished spans and events (see Testing reference)
client.exchanges lists TestExchange pairs for buffered calls. await client.drain_tasks() waits for background tasks and open spans before more assertions—see Testing — pattern 3. Do not call drain_tasks from inside work scheduled with app.create_task (deadlock risk).
Cookies, redirects, multipart
Same as the Testing reference: cookie jar updates, follow_redirects on buffered calls, multipart files with optional form data.
Compression and large bodies
Buffered get / post reassemble chunked bodies and decode Content-Encoding. TestClient(..., compression=CompressionConfig(...)) can mirror production codec defaults. For stream, prefer identity encoding when using iter_bytes.
Related: Testing — patterns 1–3 (buffered, stream, background) · Reading and writing Datastar signals (building requests with a datastar query or JSON body) · Request