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() takes data=(form-encoded),
json= (JSON body), or raw body=bytes. Experimental HTTP QUERY routes use
query() with the same body encodings, but reject mixed body sources. put()
anddelete() 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"})
# Safe, body-bearing HTTP QUERY with form data
response = await client.query(
"/search",
data={"category": "books", "year": "2026"},
)
# 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
query() accepts exactly one of body=, data=, or json=and delegates to
the same ASGI request path asrequest("QUERY", ...). Form data is encoded as
application/x-www-form-urlencoded; JSON is encoded as application/json.
For raw bytes, declare the route's media type explicitly:
response = await client.query(
"/search",
body=b"category=books&year=2026",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
See Experimental HTTP QUERY for route declarations, GET fallbacks, and deployment constraints.
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=. For htmx 4, pass source= and request_type=; the latter also defaults Accept to text/html:
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
htmx4 = await client.fragment(
"/search?q=test",
target="div#results",
source="input#search",
request_type="partial",
)
assert htmx4.status == 200
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.
Boosted navigation requests
Boosted shell navigation is not the same request shape as a narrow fragment
swap. Useboosted()to send the headers htmx sends for a boosted link:
HX-Request: true, HX-Boosted: true, and the required HX-Targetoutlet.
Pass the target element ID as htmx sends it in the header, without a CSS#.
async def test_boosted_project_navigation():
async with TestClient(app) as client:
response = await client.boosted("/projects/apollo", target="main")
assert response.status == 200
assert 'id="page-content"' in response.text
| Request | Helper | Intended response |
|---|---|---|
| Browser page load | client.get(...) |
Full page |
| Narrow htmx target | client.fragment(..., target="results") |
Target block only |
| Htmx 4 narrow target | client.fragment(..., target="div#results", source="button#go", request_type="partial") |
Target block only |
| Boosted shell outlet | client.boosted(..., target="main") |
Page/ mounted-page outlet negotiation |
For shell outlets that usehx-select, a negotiated Pageresponse can carry
the full shell document with fragment render intent so the browser selects the
declared outlet. A rawTemplatestill has full-page intent and is unsafe for a
boosted target.RouteSmokeCase(mode="boosted")distinguishes those cases.
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, narrow fragment, boosted outlet, status-only, or
both(full page plus narrow fragment):
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("/search?q=chirp", mode="fragment",
block="results", target="results"),
RouteSmokeCase("/projects/apollo", mode="boosted",
block="page_root", target="main"),
RouteSmokeCase("/health", mode="status"),
])
Failures include the path, request intent, observed response shape, status, and
any supplied target, route name, template, or block. A full-pageTemplate
returned to a boosted shell target therefore identifies the route and target
instead of silently passing as a valid outlet response.
Report compiled-transition evidence
WithAppConfig(debug=True), typed responses include a bounded return trace
correlated to the frozen application program. Usetransition_coverageto
compare realTestClientresponses with request modes or compiled transition
IDs that the test deliberately expects:
from chirp.testing import transition_coverage
responses = await assert_route_smoke(client, [
RouteSmokeCase("/projects", mode="full_page"),
RouteSmokeCase("/projects", mode="boosted", target="main"),
])
report = transition_coverage(
responses,
expected_modes=("normal", "boosted", "targeted"),
)
assert report.untested_modes == ("targeted",)
This is runtime response evidence, not a browser substitute. Keep Playwright coverage for DOM swaps, history, focus, and View Transition behavior.