HTTP Test Client
The HTTP test client lets you send requests through your router and make assertions on responses without starting a server. It handles cookies, redirects, and content-type headers automatically.
Creating a test client
Section titled “Creating a test client”TestClient is generic over your App type (the value returned by Router.define()). Initialize it with an allocator and defer deinit():
const std = @import("std");const zzz = @import("zzz");
const App = Router.define(.{ .routes = &.{ /* ... */ } });
test "example" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
const resp = try client.get("/"); try resp.expectOk();}Simple request methods
Section titled “Simple request methods”The client provides convenience methods for common HTTP verbs:
| Method | Signature | Content-Type |
|---|---|---|
get | get(path) !TestResponse | none |
post | post(path, body) !TestResponse | none |
put | put(path, body) !TestResponse | none |
patch | patch(path, body) !TestResponse | none |
delete | delete(path) !TestResponse | none |
postJson | postJson(path, json_body) !TestResponse | application/json |
putJson | putJson(path, json_body) !TestResponse | application/json |
patchJson | patchJson(path, json_body) !TestResponse | application/json |
postForm | postForm(path, form_body) !TestResponse | application/x-www-form-urlencoded |
test "CRUD operations" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
// Create const create_resp = try client.postJson("/api/items", "{\"name\":\"widget\"}"); try create_resp.expectCreated();
// Read const get_resp = try client.get("/api/items/1"); try get_resp.expectOk();
// Update const put_resp = try client.putJson("/api/items/1", "{\"name\":\"gadget\"}"); try put_resp.expectOk();
// Delete const del_resp = try client.delete("/api/items/1"); try del_resp.expectNoContent();}Request builder
Section titled “Request builder”For requests that need custom headers, query strings, or multipart bodies, use the chainable RequestBuilder:
test "request with custom headers and query" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
var builder = client.request(.POST, "/api/upload"); const resp = try builder .header("X-Request-Id", "test-123") .header("Authorization", "Bearer tok_abc") .query("format=json&verbose=true") .jsonBody("{\"data\":\"value\"}") .send();
try resp.expectOk();}Builder methods
Section titled “Builder methods”| Method | Description |
|---|---|
.header(name, value) | Add a custom header (up to 8 per request) |
.jsonBody(json) | Set body with application/json content type |
.formBody(form) | Set body with application/x-www-form-urlencoded content type |
.textBody(text) | Set body with text/plain content type |
.multipartBody(allocator, parts) | Build a multipart/form-data body from parts |
.query(qs) | Set the query string (without the leading ?) |
.send() | Dispatch the request and return a TestResponse |
Multipart file uploads
Section titled “Multipart file uploads”Use MultipartPart to build multipart/form-data requests for file upload testing:
test "file upload" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
var builder = client.request(.POST, "/api/upload"); const resp = try builder.multipartBody(std.testing.allocator, &.{ .{ .name = "description", .value = "Profile photo" }, .{ .name = "file", .filename = "photo.jpg", .content_type = "image/jpeg", .data = "FAKE_IMAGE_DATA", }, }).send();
try resp.expectOk();}The MultipartPart struct fields:
| Field | Type | Description |
|---|---|---|
name | []const u8 | Form field name (required) |
value | ?[]const u8 | Text value for simple fields |
filename | ?[]const u8 | Filename for file parts |
content_type | ?[]const u8 | MIME type for file parts |
data | ?[]const u8 | Raw data for file parts (takes precedence over value) |
Response assertions
Section titled “Response assertions”TestResponse provides assertion methods that use std.testing internally and return errors on failure.
Status assertions
Section titled “Status assertions”try resp.expectStatus(.ok); // Any status codetry resp.expectOk(); // 200try resp.expectCreated(); // 201try resp.expectNoContent(); // 204try resp.expectBadRequest(); // 400try resp.expectUnauthorized(); // 401try resp.expectForbidden(); // 403try resp.expectNotFound(); // 404Body assertions
Section titled “Body assertions”try resp.expectBody("exact match"); // Exact body matchtry resp.expectBodyContains("partial"); // Substring matchtry resp.expectEmptyBody(); // Body is empty or nullHeader assertions
Section titled “Header assertions”try resp.expectHeader("Content-Type", "application/json"); // Exact header valuetry resp.expectHeaderContains("Content-Type", "json"); // Header contains substringtry resp.expectHeaderExists("X-Request-Id"); // Header is presentJSON assertions
Section titled “JSON assertions”// Check that body contains "name":"alice" (handles optional space after colon)try resp.expectJson("name", "alice");
// Check that body contains an arbitrary substring (useful for JSON fragments)try resp.expectJsonContains("\"status\":\"active\"");Cookie assertions
Section titled “Cookie assertions”try resp.expectCookie("session"); // Set-Cookie header exists for nametry resp.expectCookieValue("session", "abc123"); // Set-Cookie has specific valueRedirect assertions
Section titled “Redirect assertions”When follow_redirects is disabled, you can assert on redirect responses:
test "redirect response" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit(); client.follow_redirects = false;
const resp = try client.get("/old-path"); try resp.expectRedirect("/new-path"); // Asserts 3xx status and Location header}Default headers
Section titled “Default headers”Set headers that are included in every request. This is useful for authentication tokens or API keys:
test "authenticated requests" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit(); client.setDefaultHeader("Authorization", "Bearer tok_secret");
// Both requests include the Authorization header const resp1 = try client.get("/api/me"); try resp1.expectOk();
const resp2 = try client.get("/api/settings"); try resp2.expectOk();}You can set up to 8 default headers. Setting a header with the same name replaces the previous value.
Cookie persistence
Section titled “Cookie persistence”The test client includes a built-in CookieJar that automatically:
- Captures
Set-Cookieheaders from responses - Sends matching cookies back on subsequent requests
- Respects cookie
Pathattributes - Handles cookie deletion via
Max-Age=0
test "cookie-based session" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
// Login sets a cookie _ = try client.post("/login", "user=alice&pass=secret");
// Subsequent requests automatically include the session cookie const resp = try client.get("/profile"); try resp.expectOk(); try resp.expectBody("alice");}Redirect following
Section titled “Redirect following”By default, the client follows redirects (301, 302, 303, 307, 308) up to 5 hops. A 303 redirect changes the method to GET and drops the body, per the HTTP specification.
test "follows redirect" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit(); // client.follow_redirects is true by default
const resp = try client.get("/old"); // resp contains the final destination response try resp.expectOk(); try resp.expectBody("new page");}test "inspect redirect" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit(); client.follow_redirects = false;
const resp = try client.get("/old"); try resp.expectRedirect("/new");}You can also change the maximum redirect depth:
client.max_redirects = 10;Resetting client state
Section titled “Resetting client state”Call reset() to clear all cookies, tracked responses, and free arena memory. This is useful when testing multiple independent flows in a single test:
test "reset between flows" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
// First flow _ = try client.post("/login", null); const resp1 = try client.get("/profile"); try resp1.expectOk();
// Reset -- clears cookies and responses client.reset();
// Second flow starts fresh const resp2 = try client.get("/profile"); try resp2.expectUnauthorized();}Testing with middleware
Section titled “Testing with middleware”Since the test client calls your router’s handler directly, all middleware in your pipeline runs during tests. There is no special setup needed:
const App = Router.define(.{ .middleware = &.{authMiddleware}, .routes = &.{ Router.get("/protected", struct { fn handle(ctx: *Context) !void { ctx.text(.ok, "secret data"); } }.handle), },});
test "middleware runs in tests" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
// Without auth header, middleware rejects the request const resp1 = try client.get("/protected"); try resp1.expectUnauthorized();
// With auth header, request passes through middleware client.setDefaultHeader("Authorization", "Bearer valid_token"); const resp2 = try client.get("/protected"); try resp2.expectOk(); try resp2.expectBody("secret data");}Testing JSON endpoints
Section titled “Testing JSON endpoints”A common pattern for testing API endpoints that accept and return JSON:
test "JSON API endpoint" { var client = zzz.testing.TestClient(App).init(std.testing.allocator); defer client.deinit();
const resp = try client.postJson("/api/users", "{\"name\":\"alice\",\"email\":\"a@example.com\"}"); try resp.expectCreated(); try resp.expectHeaderContains("Content-Type", "application/json"); try resp.expectJson("name", "alice"); try resp.expectJsonContains("\"email\"");}Next steps
Section titled “Next steps”- Testing Overview — introduction to the full testing toolkit.
- WebSocket Testing — testing channels with
TestChannel. - Database Sandbox — transaction-based test isolation, factories, and seeding.