Testing
Run the HTTP stack in-process with stario.testing.TestClient: same status codes, headers, cookies, and bodies as production, without a listening socket.
Always use async with TestClient(...) before calling get, post, request, or stream. Exit runs disconnect, optional app shutdown, and drain_tasks().
Practical recipes: Testing with TestClient.
pytest-asyncio: set asyncio_mode = auto under [tool.pytest.ini_options], or mark async tests with @pytest.mark.asyncio.
Bootstrap vs inline App: pass your production bootstrap for integration tests, or construct a small App in the test for narrow cases. With a bootstrap, client.app is available only inside the context manager. Use TestClient(bootstrap, app_factory=build_app) when tests must not share mutable state on one App — put routes in build_app(), use bootstrap for wiring and teardown. If you load the app with async with aload_app(bootstrap) as app, pass TestClient(app, owns_shutdown=False) so the client does not signal shutdown while aload_app still owns teardown.
URLs: Stario 4 has no url_for or route name=. Build paths with UrlPath(...).href() or AssetManifest.href() the same way application code does.
1. Request and response (buffered)
Use await client.get(...), post(...), or request(...) when the handler finishes the exchange in one go. Assert request shape (params, json, headers, cookies) and response (r.status_code, r.text, r.json(), r.headers, r.cookies).
from stario.routing import UrlPathfrom myapp.app import bootstrapfrom stario.testing import TestClient items = UrlPath("/items") async def test_list_items(): async with TestClient(bootstrap) as client: r = await client.get(items.href(), params={"page": "2"}) assert r.status_code == 200 assert "items" in r.json() 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"For a tiny app without bootstrap:
from stario import App, Context, Writerfrom stario.testing import TestClient async def test_ping_inline(): app = App() async def ping(c: Context, w: Writer) -> None: w.write_headers(200) w.end() app.get("/ping", ping) async with TestClient(app) as client: assert (await client.get("/ping")).status_code == 200r.span_id is the root request span id for assertions on client.tracer. Request shape: Request.
2. Long-lived response (stream)
When the handler keeps the connection open (SSE, chunked bodies), use async with client.stream("GET", path, ...). Read with await r.body(), async for chunk in r.iter_bytes(), or for SSE async for ev in r.iter_events(). Leaving the block disconnects the request and awaits the handler.
stream does not follow redirects. Send Accept-Encoding: identity when using iter_bytes() on compressed responses.
from stario import App, Context, Writerfrom stario.testing import TestClient async def test_stream_joins_byte_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: parts = [chunk async for chunk in r.iter_bytes()] assert b"".join(parts) == b"hello"Assert telemetry with r.span_id and client.tracer.find_span(..., root_id=r.span_id). client.exchanges lists recent buffered round-trips.
3. Background work after the response
Buffered requests return when the response body is finished; work scheduled with app.create_task may still run. Call await client.drain_tasks() after the request to wait until the app task queue is quiet and tracer spans have settled.
Do not call drain_tasks() from inside a coroutine scheduled with app.create_task — it can deadlock (same risk as awaiting drain from within handler-scheduled work).
For assertions on late work, use a child span: start() in the handler, end() when the background task finishes, then inspect client.tracer after drain_tasks().
import asyncio import stario.responses as responsesfrom stario import App, Context, Writerfrom stario.testing import TestClient async def test_response_then_background(): app = App() async def handler(c: Context, w: Writer) -> None: child = c.span.step("work.after_response") child.start() async def bg(): try: await asyncio.sleep(0.02) child.attr("work.result", "done") finally: child.end() c.app.create_task(bg()) responses.empty(w, 204) app.post("/action", handler) async with TestClient(app) as client: r = await client.post("/action") assert r.status_code == 204 await client.drain_tasks() step = client.tracer.find_span("work.after_response", root_id=r.span_id) assert step is not None assert step.attributes.get("work.result") == "done"API
class TestClient(app_or_bootstrap, *, app_factory=None, owns_shutdown=True, base_url='http://testserver', headers=None, cookies=None, follow_redirects=True, max_redirects=20, compression=None, request_timeout=30.0)
Async HTTP client: exercise an App in-process.
Pass a fully wired app or the same bootstrap async generator your program uses in production. Always enter async with TestClient(...) before calling request / stream / …; then app is the live application.
Buffered requests — request waits for the entire response body and returns TestResponse. The get / head / … helpers are thin wrappers with the same keyword arguments. Redirects are followed up to max_redirects unless overridden per call.
Streaming — stream provides TestStreamResponse after headers; it does not follow redirects. Prefer Accept-Encoding: identity when using TestStreamResponse.iter_bytes. timeout applies to the full exchange (headers and body reads). Leaving the stream block disconnects that exchange and awaits the handler.
Telemetry — each response exposes span_id; finished data is on tracer. Buffered calls append TestExchange rows to exchanges.
Exit — buffered exchanges are disconnected, drain_tasks runs, then bootstrap teardown (if any) matches normal app shutdown.
async TestClient.drain_tasks()
Wait until App.drain_tasks is quiet and tracer has no open spans.
Called automatically after signalling disconnect when exiting the client context. Unsafe to call from work scheduled via app.create_task (can deadlock).
async TestClient.request(method, url, *, params=None, headers=None, cookies=None, json=None, data=None, files=None, content=None, follow_redirects=None, timeout=None)
Issue an arbitrary HTTP method and return a fully buffered TestResponse.
TestClient.stream(method, url, *, params=None, headers=None, cookies=None, json=None, data=None, files=None, content=None, timeout=None)
Start a request and yield TestStreamResponse once headers are available.
Does not follow redirects (inspect Location yourself). On context exit, signals client disconnect for this exchange and awaits the handler coroutine. timeout caps the whole exchange, including body iteration after headers. Use Accept-Encoding: identity when reading iter_bytes so the body is not compressed.
async TestClient.get(url, **kwargs)
async TestClient.head(url, **kwargs)
async TestClient.post(url, **kwargs)
async TestClient.put(url, **kwargs)
async TestClient.patch(url, **kwargs)
async TestClient.delete(url, **kwargs)
async TestClient.options(url, **kwargs)
class TestResponse(status_code, url, headers, content, request, span_id, _disconnect_future, history=<factory>, cookies=<factory>)
Buffered HTTP result from TestClient.get, TestClient.post, etc.
Fields
- status_code(int):—
- url(str):—
- headers(Headers):—
- content(bytes):—
- request(ClientRequest):—
- span_id(UUID):—
- _disconnect_future(Future):—
- history(list):default:
factory - cookies(dict):default:
factory
The body is fully buffered; Content-Encoding is decoded when present. Pair span_id with TestClient.tracer for assertions.
TestResponse.json()
TestResponse.raise_for_status()
class TestStreamResponse(status_code, url, headers, request, span_id, cookies, sink, _body_start, _chunked, _content_length, _disconnect_future, _app_task, _deadline=None)
Streaming result from TestClient.stream once response headers exist.
Fields
- status_code(int):—
- url(str):—
- headers(Headers):—
- request(ClientRequest):—
- span_id(UUID):—
- cookies(dict):—
- sink(GrowingSink):—
- _body_start(int):—
- _chunked(bool):—
- _content_length(Union):—
- _disconnect_future(Future):—
- _app_task(Task):—
- _deadline(Union):default:
None
Use body, iter_bytes, or iter_events to read the entity body. Leaving the stream context disconnects this exchange and awaits the app task.
async TestStreamResponse.body()
Concatenate all body chunks after transfer decoding (same as iterating iter_bytes).
async TestStreamResponse.iter_bytes()
Yield decoded body chunks as they arrive (chunked / fixed-length / until close).
Only identity Content-Encoding is supported; request Accept-Encoding: identity or use buffered methods for compression.
async TestStreamResponse.iter_events()
Parse text/event-stream into dicts (keys such as event, id, data).
class TestTracer()
Test-side view of telemetry for TestClient.
Implements the stario.telemetry.Tracer protocol for request dispatch. Assertions in tests should use the query helpers below; they only return finished span snapshots.
TestTracer.create(name, attributes=None, /, *, parent=None)
TestTracer.find_span(name, *, root_id=None, parent_id=None)
First finished span named name, in start-time order.
When several requests reuse the same span name, pass root_id=r.span_id (from the matching TestResponse) so you match the right subtree. parent_id requires an exact parent link.
TestTracer.get_event(span_id, event_name, *, index=0)
The index-th TelemetryEvent named event_name, or None.
TestTracer.get_events(span_id, *, name=None)
Events recorded on a finished span; filter with name when set.
TestTracer.get_span(span_id)
Return the finished TelemetrySpan for span_id, or None.
Open spans and unknown ids yield None.
TestTracer.has_attribute(span_id, key, value=<object object at 0x7505869a9110>)
Whether the finished span's attributes include key.
Pass value to require equality; omit it to assert presence only.
TestTracer.has_event(span_id, event_name)
Whether get_events(span_id) contains at least one event_name.
TestTracer.has_open_spans()
True while any span created through this tracer has not been ended.
TestTracer.on_end(span)
TestTracer.stats()
aload_app(bootstrap, *, app_factory=None, tracer=None)
Load bootstrap like production; emits server.startup / server.shutdown spans.
Pass tracer to share one TestTracer with TestClient (same instance as client.tracer when the client wires bootstrap).