Testing¶
Veloce ships an in-memory TestClient and
AsyncTestClient. They construct ASGI
scopes directly and call the app's __call__ surface, so the radix router,
dependency resolver, middleware chain, response encoder, and the lifespan
handshake all run — no socket, no separate server.
Both clients ship with Veloce; there is nothing extra to install.
Creating a client¶
app.test_client() returns a sync client bound to your application:
from veloce import Veloce
app = Veloce()
@app.get("/ping")
async def ping():
return {"pong": True}
def test_ping():
client = app.test_client()
response = client.get("/ping")
assert response.status_code == 200
assert response.json() == {"pong": True}
Construct it explicitly when you prefer the class form:
Note
TestClient(app) runs the app's startup lifecycle once at construction,
so startup hooks have already populated app.state before your first call.
The matching shutdown runs on close() or context-manager exit.
Making requests¶
The client mirrors the HTTP verbs and returns a response object exposing these attributes:
| Attribute |
|---|
status_code |
json() |
text |
headers |
content_type |
cookies |
client.get("/items", params={"page": "2"})
client.post("/users", json={"name": "ada", "email": "ada@example.com"})
client.post("/login", data={"username": "ada", "password": "secret"})
client.post("/upload", files={"f": ("data.csv", b"a,b,c")})
client.delete("/items/1")
Cookies set by the application persist across calls on the same client. Pass raw
bytes with content=..., form fields with data=..., and a JSON body with
json=... — the matching Content-Type is set for you unless you override it.
Streaming a request body¶
post, put, patch, and request accept stream=... to feed the request
body as multiple ASGI http.request chunks instead of a single frame, which
exercises handlers that consume the body incrementally. Pass a sync Iterable
or an AsyncIterable of bytes/str chunks; stream takes precedence over
and excludes json/data/content/files:
Following redirects¶
Redirects are not followed by default. Pass follow_redirects=True per call,
or set it once on the client:
client = app.test_client(follow_redirects=True)
resp = client.get("/old-path") # 301/302/303 → final 200
assert resp.status_code == 200
Note
301, 302, and 303 rewrite the next hop to GET with an empty body (every
browser does the same); 307 and 308 preserve the method and body. The client
raises after 10 hops to catch redirect loops.
Lifespan startup and shutdown¶
Use the sync client as a context manager to run startup / shutdown events
around the block:
from veloce import Veloce
app = Veloce()
started = {}
@app.on_startup
async def warm_cache():
started["ready"] = True
def test_with_lifespan():
with app.test_client() as client:
assert started["ready"] is True # startup ran on enter
assert client.get("/ping").status_code in (200, 404)
# shutdown handlers have run here
See Lifespan and events for registering startup/shutdown hooks
and the lifespan= context-manager form.
Async tests with AsyncTestClient¶
When the test itself is async def, use
AsyncTestClient so each request is
awaited on the test's own running loop instead of a private one. It is always
used as an async context manager — requests are refused outside async with:
from veloce import Veloce
app = Veloce()
@app.get("/ping")
async def ping():
return {"pong": True}
async def test_ping_async():
async with app.async_test_client() as client:
resp = await client.get("/ping")
assert resp.status_code == 200
assert resp.json() == {"pong": True}
The request methods (get / post / ...) are coroutines, so each call is
awaited. Cookie persistence, redirect following, and the JSON / form / files
body shapes match the sync TestClient exactly.
Note
Under pytest with asyncio_mode = "auto", plain async def test_*
functions are collected automatically — do not decorate them with
@pytest.mark.asyncio.
WebSocket testing stays on the sync TestClient.websocket_connect.
Testing WebSockets¶
websocket_connect opens an in-memory WebSocket against an @app.websocket
route and returns a context manager. Send and receive text, bytes, or JSON:
from veloce import Veloce, WebSocket
app = Veloce()
@app.websocket("/ws")
async def echo(ws: WebSocket):
await ws.accept()
msg = await ws.receive_text()
await ws.send_text(f"echo: {msg}")
def test_ws_echo():
client = app.test_client()
with client.websocket_connect("/ws") as ws:
ws.send_text("hello")
assert ws.receive_text() == "echo: hello"
JSON and bytes frames¶
send_json / receive_json serialise through the same encoder the framework
uses, and send_bytes / receive_bytes carry binary frames:
with client.websocket_connect("/ws") as ws:
ws.send_json({"op": "ping"})
assert ws.receive_json() == {"op": "pong"}
ws.send_bytes(b"\x00\x01")
assert ws.receive_bytes() == b"\x00\x01"
Subprotocols¶
Pass the offered subprotocols to websocket_connect; the negotiated one is on
accepted_subprotocol after the handshake:
with client.websocket_connect("/ws", subprotocols=["chat", "v1"]) as ws:
assert ws.accepted_subprotocol == "chat"
Native subprotocol negotiation differs on the app.run() path
Under an ASGI server (and the in-memory TestClient) the handshake can
select a subprotocol. On the native app.run() transport the 101 is sent
before accept() runs, so accept(subprotocol=...) raises there. Test
subprotocol negotiation through the ASGI surface, which TestClient drives.
A handler that closes the socket during the handshake (instead of accepting)
raises a RuntimeError from websocket_connect, so a rejected connection fails
the test loudly rather than hanging.
Overriding dependencies¶
Swap real dependencies — databases, auth, external APIs — for fakes by writing
into app.dependency_overrides. The key is the original Depends callable; the
value is the replacement:
from veloce import Depends, TestClient, Veloce
def get_db():
raise RuntimeError("real DB not available in tests")
def fake_db():
return {"users": ["ada"]}
app = Veloce()
@app.get("/users")
async def list_users(db=Depends(get_db)):
return db["users"]
app.dependency_overrides[get_db] = fake_db
client = TestClient(app)
resp = client.get("/users")
assert resp.status_code == 200
assert resp.json() == ["ada"]
dependency_overrides is a plain dict. Reset it between tests so an override
does not leak into the next case — app.dependency_overrides.clear() (or pop a
single key) in your fixture teardown:
See Dependency injection for the full pattern.
Mutating the session¶
When the app has SessionMiddleware, session_transaction() lets a test seed
the session outside a request. It yields a session dict pre-loaded from the
current cookie; on block exit the session is re-signed and stored in the cookie
jar, so the next request carries it:
from veloce import Veloce
from veloce.middleware.sessions import SessionMiddleware
app = Veloce()
app.add_middleware(SessionMiddleware, secret_key="test-secret-do-not-ship")
@app.get("/whoami")
async def whoami(request):
return {"user_id": request.session.get("user_id")}
def test_session_seed():
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = 7
resp = client.get("/whoami")
assert resp.json() == {"user_id": 7}
Note
session_transaction() raises RuntimeError if the app has no
SessionMiddleware — there is no session to mutate without it.
Surfacing handler exceptions¶
By default an unhandled exception in a handler is converted to a 500 response,
just as in production, so the test sees resp.status_code == 500. To make the
exception propagate out of the call and fail the test with the real traceback,
enable propagation:
Propagation is also implied when both DEBUG and TESTING are enabled.
Veloce uses PROPAGATE_EXCEPTIONS, not a TestClient kwarg
The Veloce TestClient has no raise_server_exceptions, backend, or
root_path constructor argument. Control exception propagation through
app.config["PROPAGATE_EXCEPTIONS"] (or DEBUG + TESTING), not a client
flag. The full constructor signature is:
AsyncTestClient takes the same three arguments.
Next steps¶
- Wire fakes into your routes — see Dependency injection.
- Run startup and shutdown hooks under test — see Lifespan and events.
- Swap a real database for an in-memory fake — see Databases.
- Full signatures are in the API reference.