Testing¶
TestClient calls the application in-process — no socket, no server, no port.
That makes tests fast, but the reason it exists is coverage: the same test can
be run through the WSGI adapter and the ASGI adapter, and a change that works
on one but not the other fails immediately.
The pattern to copy¶
# conftest.py
import pytest
from slowfw.testing import TestClient
from myapp.main import app
@pytest.fixture(params=["wsgi", "asgi"])
def client(request):
"""Every test using this fixture runs twice, once per protocol."""
with TestClient(app, protocol=request.param) as c:
yield c
def test_health(client):
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
Adopt this on day one. Retrofitting it later means auditing every test you already wrote.
Making requests¶
client.get("/users", params={"limit": 10})
client.post("/users", json={"email": "a@b.c"})
client.post("/login", data={"username": "x", "password": "y"}) # form-encoded
client.post("/raw", data=b"bytes", headers={"content-type": "application/octet-stream"})
client.patch("/users/1", json={"done": True})
client.delete("/users/1")
client.head("/users")
client.options("/users", headers={"origin": "https://app.example"})
File uploads¶
client.post("/avatar", files={"avatar": ("me.png", open("me.png", "rb"), "image/png")})
client.post("/avatar", files={"avatar": ("me.png", b"raw bytes", "image/png")})
# Fields and files together, which is the usual shape:
client.post("/avatar", data={"caption": "my face"}, files={"avatar": (...)})
# Several files under one field name:
client.post("/docs", files=[("docs", ("a.txt", b"a", "text/plain")),
("docs", ("b.txt", b"b", "text/plain"))])
The body is encoded as multipart/form-data and parsed by the real parser, so
a passing test means the endpoint works, not that the client was lenient.
Who is connecting¶
Anything that depends on the peer address — proxy trust, IP allow-lists, rate-limit buckets — needs to control it:
That is how you check that an untrusted client cannot spoof X-Forwarded-For
into someone else's rate-limit bucket.
Reading responses¶
response.status_code
response.json()
response.text
response.content # bytes exactly as sent
response.body # bytes, gunzipped if compressed
response.headers["content-type"] # case-insensitive
response.cookies # parsed Set-Cookie
response.ok # 2xx or 3xx
response.raise_for_status() # AssertionError with the body, if not ok
text and json() read from body, so they transparently un-gzip — a test
about a JSON payload does not have to know whether GZipMiddleware is
installed. Use content when the encoding itself is the point.
Cookies and sessions¶
Cookies persist across requests on the same client, so a login flow is just two calls:
def test_session_survives(client):
client.post("/login", data={"email": "a@b.c"})
assert client.get("/me").json()["email"] == "a@b.c"
Disable with TestClient(app, follow_cookies=False).
Startup and shutdown¶
Using the client as a context manager runs the lifecycle, which is what builds
singletons and runs on_module_init:
Without it, startup still happens lazily on the first request, but shutdown never runs — so use the context manager whenever a test asserts on teardown.
Replacing dependencies¶
Registering a provider again replaces it:
class FakeEmail:
def __init__(self):
self.sent = []
def send(self, to, subject):
self.sent.append((to, subject))
@pytest.fixture
def fake_email(app):
fake = FakeEmail()
app.provide(Provider.value(EmailService, fake))
return fake
def test_signup_sends_a_welcome(client, fake_email):
client.post("/signup", json={"email": "a@b.c"})
assert fake_email.sent == [("a@b.c", "Welcome")]
For Depends-based dependencies, prefer designing them to read from a provider
so they can be swapped the same way.
Testing units directly¶
Not everything needs a request:
def test_pagination_bounds():
with pytest.raises(FieldError):
validate_param(500, Query(20, le=100), ("query", "limit"))
def test_router_prefers_literals():
router = Router()
router.add(Route("/users/me", "GET", handler, "me"))
router.add(Route("/users/{id:int}", "GET", handler, "by_id"))
assert router.match("GET", "/users/me")[0].name == "me"
Asserting parity explicitly¶
For anything touching dispatch, request parsing, or response writing, assert that the two protocols agree rather than testing them separately:
def test_identical_on_both_protocols():
wsgi = TestClient(app, protocol="wsgi")
asgi = TestClient(app, protocol="asgi")
a, b = wsgi.get("/users/1"), asgi.get("/users/1")
assert a.status_code == b.status_code
assert a.content == b.content
Asserting the fast path¶
The performance claim is testable, so test it:
from slowfw.concurrency import _LoopThread, shutdown_loop_thread
def test_sync_wsgi_never_starts_a_loop():
shutdown_loop_thread()
TestClient(app, protocol="wsgi").get("/plain")
assert _LoopThread._instance is None
If a refactor accidentally makes a framework internal async def in a way that
suspends, this catches it.