Overview
TestClient runs requests straight through your app's ASGI handler in-process -- no socket, no running server, no test HTTP layer. It builds the same Request and returns the same Responseyour app uses in production, so a passing test exercises the real code path.
Reach for it in pytest to assert status, body, and fragment-vs-full-page rendering. It also runs your startup and per-worker hooks, so apps that open a DB connection or HTTP client inon_worker_startupbehave exactly as they do in production.
Basic Usage
from chirp.testing import TestClient
async def test_homepage():
async with TestClient(app) as client:
response = await client.get("/")
assert response.status == 200
assert "Hello" in response.text
The TestClientis an async context manager. It handles app startup/shutdown lifecycle automatically.
HTTP Methods
Every method accepts aheaders= dict. post() also takes data= (form-encoded), json= (JSON body), or raw body= bytes; put() and delete() take only headers= and (for put) body=.
async def test_methods():
async with TestClient(app) as client:
# GET with custom headers
response = await client.get("/api/data", headers={
"Authorization": "Bearer token123",
"Accept": "application/json",
})
assert response.status == 200
# POST with JSON
response = await client.post("/users", json={"name": "Alice"})
assert response.status == 201
# POST with form data
response = await client.post("/login", data={"username": "alice", "password": "secret"})
# PUT with a raw body (put() has no json= / data= shortcut)
import json
response = await client.put(
"/users/1",
body=json.dumps({"name": "Alice Updated"}).encode(),
headers={"Content-Type": "application/json"},
)
# DELETE
response = await client.delete("/users/1")
assert response.status == 200
Fragment Requests
To simulate an htmx request, send theHX-Request header so your handler renders a fragment instead of a full page. The fragment() convenience method sets that header for you and exposes target=, trigger=, and history_restore=:
async def test_fragment():
async with TestClient(app) as client:
response = await client.fragment("/search?q=test", target="#results")
assert response.status == 200
assert '<div id="results">' in response.text
Use the fragment and SSE assertions (assert_is_fragment, assert_is_full_page, ...) to check fragment-vs-full-page rendering without hand-writing <html>string checks.
Cookies and sessions
Response Properties
The returned object is the sameResponseyour handlers produce. The fields you assert on most:
| Property | Type | Description |
|---|---|---|
status |
int |
HTTP status code |
text |
str |
Response body as a string |
json |
property | Body parsed as JSON; raisesValueErroron non-JSON |
header(name, default=None) |
method | First matching header value (case-insensitive) |
headers |
tuple[tuple[str, str], ...] |
Raw header pairs |
cookies |
tuple[SetCookie, ...] |
Set-Cookievalues on the response |
Read a single header with theheader() method rather than indexing headers:
assert response.header("Content-Type") == "application/json"
Using with pytest
import pytest
from myapp import app
@pytest.fixture
async def client():
async with TestClient(app) as c:
yield c
async def test_homepage(client):
response = await client.get("/")
assert response.status == 200
Smoke-test a whole route set
When you want one test to prove a set of routes still renders (in CI, after a
refactor),assert_route_smokeruns each route through the client and checks its
render mode -- full page, fragment, status-only, orboth:
from chirp.testing import RouteSmokeCase, TestClient, assert_route_smoke
async def test_showcase_routes(app):
async with TestClient(app) as client:
await assert_route_smoke(client, [
RouteSmokeCase("/", mode="full_page", name="home"),
RouteSmokeCase("/islands/remount", mode="both",
template="islands/remount.html", block="island_mount"),
RouteSmokeCase("/health", mode="status"),
])
Failures include the path, render intent, and any supplied route name, template, or block, so a template render error points straight back to the broken route.