diff --git a/docs/source/testing.rst b/docs/source/testing.rst index 3ae625a..0e926af 100644 --- a/docs/source/testing.rst +++ b/docs/source/testing.rst @@ -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.