Expand testing docs with prose, examples, and tips

New sections: request validation, headers/cookies, custom error
handlers, before/after hooks, and practical tips. Every section
now explains the why, not just the how.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:00:29 -04:00
parent a2a2ae21ff
commit b3c55f68d9
+143 -14
View File
@@ -3,7 +3,9 @@ Testing
Responder includes a built-in test client powered by Starlette's
``TestClient``. You don't need to start a server — tests run in-process,
making them fast and reliable.
making them fast and reliable. There's no separate test server to manage,
no ports to allocate, and no race conditions to worry about. Just import
your app and start making requests.
Getting Started
@@ -22,7 +24,9 @@ Given a simple application in ``api.py``::
if __name__ == "__main__":
api.run()
You can test it with pytest::
You can test it with pytest. Every Responder ``API`` instance has a
``requests`` property that gives you a test client — use it exactly like
you'd use ``requests`` or ``httpx``::
# test_api.py
import api as service
@@ -35,12 +39,15 @@ Run your tests::
$ pytest
That's really all there is to it. No configuration, no test server setup.
Using Fixtures
--------------
For larger test suites, use pytest fixtures to share the API instance
across tests::
For larger test suites, pytest fixtures keep things organized. Create a
fixture that returns your API instance, and every test gets a fresh
reference to it::
import pytest
import api as service
@@ -62,13 +69,16 @@ across tests::
assert r.json() == {"key": "value"}
The ``api.url_for()`` method generates a URL for a given route endpoint,
so you don't have to hard-code paths in your tests.
so you don't have to hard-code paths in your tests. If you rename a route
later, your tests won't break.
Testing JSON APIs
-----------------
Send JSON data and check the response::
Most APIs send and receive JSON. The test client makes this natural — pass
``json=`` to send a JSON body, and call ``.json()`` on the response to
parse it::
def test_create_item(api):
@api.route("/items")
@@ -81,11 +91,45 @@ Send JSON data and check the response::
assert r.status_code == 201
assert r.json() == {"created": {"name": "widget"}}
You can also test content negotiation by setting the ``Accept`` header::
r = api.requests.get("/data", headers={"Accept": "application/x-yaml"})
assert "key: value" in r.text
Testing Request Validation
--------------------------
If you're using Pydantic models for request validation, you can test
that invalid inputs are properly rejected::
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
def test_validation(api):
@api.route("/items", methods=["POST"], request_model=Item)
async def create(req, resp):
data = await req.media()
resp.media = data
# Valid request
r = api.requests.post("/items", json={"name": "thing", "price": 9.99})
assert r.status_code == 200
# Missing required field
r = api.requests.post("/items", json={"name": "thing"})
assert r.status_code == 422
assert "errors" in r.json()
Testing File Uploads
--------------------
Send files using the ``files`` parameter::
File uploads use the ``files`` parameter, just like the ``requests``
library. Each file is a tuple of ``(filename, content, content_type)``::
def test_upload(api):
@api.route("/upload")
@@ -98,10 +142,29 @@ Send files using the ``files`` parameter::
assert r.json() == {"received": ["doc"]}
Testing Headers and Cookies
----------------------------
Check response headers and cookies just like you would with any HTTP
client::
def test_headers(api):
@api.route("/")
def view(req, resp):
resp.headers["X-Custom"] = "hello"
resp.cookies["session"] = "abc123"
r = api.requests.get("/")
assert r.headers["X-Custom"] == "hello"
assert "session" in r.cookies
Testing WebSockets
------------------
Use Starlette's ``TestClient`` directly for WebSocket connections::
WebSocket tests use Starlette's ``TestClient`` directly, since WebSocket
connections require a different protocol. The ``websocket_connect`` context
manager gives you a connection you can send and receive on::
from starlette.testclient import TestClient
@@ -109,19 +172,23 @@ Use Starlette's ``TestClient`` directly for WebSocket connections::
@api.route("/ws", websocket=True)
async def ws(ws):
await ws.accept()
await ws.send_text("hello")
name = await ws.receive_text()
await ws.send_text(f"hello, {name}!")
await ws.close()
client = TestClient(api)
with client.websocket_connect("/ws") as ws:
assert ws.receive_text() == "hello"
ws.send_text("world")
assert ws.receive_text() == "hello, world!"
Testing Error Handling
----------------------
To test error responses without pytest raising the exception, disable
server exception propagation::
By default, the test client raises exceptions from your route handlers,
which is usually what you want — it makes bugs obvious. But when you're
testing error handling specifically, you want to see the error response
instead. Disable exception propagation with ``raise_server_exceptions``::
from starlette.testclient import TestClient
@@ -134,12 +201,30 @@ server exception propagation::
r = client.get(api.url_for(fail))
assert r.status_code == 500
If you've registered a custom exception handler, you can test that too::
def test_custom_error(api):
@api.exception_handler(ValueError)
async def handle(req, resp, exc):
resp.status_code = 400
resp.media = {"error": str(exc)}
@api.route("/fail")
def fail(req, resp):
raise ValueError("bad input")
client = TestClient(api, raise_server_exceptions=False)
r = client.get(api.url_for(fail))
assert r.status_code == 400
assert r.json() == {"error": "bad input"}
Testing Lifespan Events
-----------------------
The test client supports lifespan events. Use ``with`` to ensure startup
and shutdown hooks run::
If your app uses startup and shutdown events (for database connections,
caches, etc.), you need the test client to trigger them. Wrap the client
in a ``with`` block — startup runs on enter, shutdown runs on exit::
def test_with_lifespan(api):
started = {"value": False}
@@ -155,3 +240,47 @@ and shutdown hooks run::
with api.requests as session:
r = session.get("http://;/")
assert r.json() == {"started": True}
Without the ``with`` block, lifespan events won't fire, which can lead to
confusing test failures if your routes depend on startup initialization.
Testing Before and After Hooks
------------------------------
Before-request and after-request hooks run automatically during tests,
just like in production. You can verify their effects on the response::
def test_hooks(api):
@api.route(before_request=True)
def add_version(req, resp):
resp.headers["X-Version"] = "3.2"
@api.after_request()
def add_timing(req, resp):
resp.headers["X-Served-By"] = "responder"
@api.route("/")
def view(req, resp):
resp.text = "ok"
r = api.requests.get("/")
assert r.headers["X-Version"] == "3.2"
assert r.headers["X-Served-By"] == "responder"
Tips
----
- **Keep tests fast.** The in-process test client is already fast — no
network overhead. Avoid ``time.sleep()`` in tests.
- **One API per test** when testing configuration. If you need a specific
``API()`` configuration (like ``cors=True``), create a new instance
in the test rather than sharing the fixture.
- **Use ``api.url_for()``** instead of hard-coded paths. It's a small
thing, but it makes refactoring painless.
- **Test the contract, not the implementation.** Assert on status codes,
response bodies, and headers — not on internal state.