Test Client

Make requests against your app without a running server

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

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