mirror of
https://github.com/kennethreitz/responder.git
synced 2026-06-05 06:46:14 +00:00
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:
+143
-14
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user