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

python
from stario.testing import TestClient
from myapp.app import bootstrap
 
 
async def test_home():
    async with TestClient(bootstrap) as client:
        r = await client.get("/")
        assert r.status_code == 200

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

python
from stario.testing import TestClient
from 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.

python
import json
 
import stario.responses as responses
from stario import App, Context, Writer
from 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"] == 1

Same 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:

python
from stario import App, Context, Writer
from 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):

python
from stario import App, Context, Writer
from 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).

python
from stario.testing import TestClient
from 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 == 200

If 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):

python
from stario.testing import TestClient
from 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

python
import pytest
from stario.testing import TestClient
from myapp.app import bootstrap
 
 
@pytest.fixture
async def client():
    async with TestClient(bootstrap) as c:
        yield c
 
 
async def test_page(client):
    assert (await client.get("/about")).status_code == 200

What to assert

  • r.text, r.json(), r.status_code, r.headers, r.cookies

  • r.span_id with client.tracer for 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