Skip to content

Testing

Handlers are ordinary functions

The whole point of the signature rules is this: a handler takes only what it needs, so a test hands it those things and calls it. No server, no socket, no fake HTTP request.

fn getUser(db: *Db, id: u32) !User {
    return db.find(id) orelse nilo.fail.notFound("no user {d}", .{id});
}

test "getUser" {
    var fake = Db.fake(.{ .id = 7 });
    try expectEqual(7, (try getUser(&fake, 7)).id);
    try expectError(error.Failed, getUser(&fake, 99));
}

Every fail function returns error.Failed, so that is what a refusal asserts on. A handler returning ?T says the same thing by answering null, so there the assertion is try expect(try getUser(&fake, 99) == null) and no error is involved at all. To check which refusal, look at the message the failure box holds — or drive the request through the test client below, where the status is on the answer.

The rest follows the same shape:

// a query struct is an ordinary struct
const page_two = try listUsers(&db, .{ .value = .{ .page = 2 } });

// so is a resolved value
const profile = try me(.{ .id = 7, .name = .static("wati") });

// a body argument is the parsed struct, not JSON text
const created = try createUser(&db, .{ .name = .static("wati"), .age = 30 });

// a binding where everything bound, which is what most tests want
const signed_up = try signUp(.ok(.{ .email = .static("wati@example.com"), .age = 31 }));

Bound(W) is the one argument a test cannot spell as a plain struct — it has private fields, because a half-filled struct is exactly what it exists to withhold. .ok(value) is the binding where every field bound. To test the other branch, drive the request through the test client below: what a handler does with a failure is a 422 on the wire, and that is the thing worth asserting on.

nilo.blocking and nilo.Mutex both work with no server under them, so a handler that uses either is still callable from a test.

Str.static("wati") is how a test makes one: text that already outlives any request, so nothing can go stale.

Handlers that write their answer

A handler that returns a value is tested by calling it. One that writes its answer — a stream, an event stream, anything sending from a *Ctx — needs somewhere to write to. So there is a client for that:

var app = nilo.App.init(testing.allocator);
defer app.deinit();
try app.get("/report.csv", report);

var client = try nilo.testing.Client.init(testing.allocator, .{});
defer client.deinit();

const answer = try client.get(&app, "/report.csv");
try testing.expectEqual(@as(u16, 200), answer.status);
try testing.expect(answer.chunked);

var buf: [4096]u8 = undefined;
try testing.expectEqualStrings("id,name\n1,wati\n", try answer.text(&buf));

It runs one request through the App with no server and no socket. Everything the request path does happens: middleware, routing, the arena, the response written to a buffer instead of a connection.

Sending a request

client.get(&app, "/path")
client.post(&app, "/path", body) with Content-Length set
client.request(&app, "PUT", "/path", body) any method
client.sendRequest(&app, .{ … }) any of the above plus headers, every field defaulted
client.send(&app, raw) the whole request written out, for a version the others don't cover

sendRequest is the one to reach for when a route reads a header:

const answer = try client.sendRequest(&app, .{
    .path = "/video.mp4",
    .headers = &.{.{ .name = "Range", .value = "bytes=0-20" }},
});
try testing.expectEqual(@as(u16, 206), answer.status);

client.setHeader("Authorization", "Bearer t") sets one for every request from then on, which is what a suite behind a bearer token wants.

send is left for what nothing else can express — an HTTP/1.0 request, a deliberately malformed one. There, write the Host yourself: every other entry point puts one in for you, and an HTTP/1.1 request without one is a 400 before it reaches a route (ADR 070). send also applies neither setHeader nor the jar — the bytes are yours, exactly as given.

Signing in, and staying signed in

Client.init(gpa, .{ .cookies = true }) keeps what the answers set and sends it back, the way a browser does. Without it a sign-in followed by a request as that user means copying the Set-Cookie out of one answer into the next request by hand.

var client = try nilo.testing.Client.init(testing.allocator, .{ .cookies = true });
defer client.deinit();

_ = try client.postWith(&app, "/sign-in", "application/x-www-form-urlencoded",
    "email=wati%40example.dev&password=hunter2");

// Carries the session cookie the sign-in set.
const answer = try client.get(&app, "/me");

It is off by default so that a suite written before it existed keeps asserting what it always asserted (ADR 086). client.cookie("session") is what the jar is holding, for a test that wants to look rather than only send.

Reading the answer

answer.status
answer.header("content-type") case-insensitive, null if absent
answer.body the bytes after the head — still chunk-framed if it was a stream
answer.text(&buf) the body as a client sees it, framing undone
answer.bytes(arena) the same, into memory the arena owns
answer.json(T, arena) the body read back as a value
answer.raw everything, exactly as it went on the wire
answer.head the status line and headers
answer.chunked whether it arrived in chunks
answer.keep_alive whether the connection could have carried another request

answer.json is there because nilo already decided how the value was written — walking a std.json.Value to pull one field out of a create was four lines at every call site (ADR 147):

const made = try answer.json(struct { id: []const u8 }, arena);
try testing.expectEqual(@as(usize, 36), made.id.len);

It undoes chunk framing first and copies everything into the arena, so what comes back survives the next request on the same client. Fields it was not asked about are ignored — you are asking a question about part of the response, not asserting its whole shape. When the shape is what you are asserting, ask for std.json.Value.

A client may be reused for as many requests as you like; each one gets a fresh arena, exactly as a real connection does between requests.

An App and a Client together

Most test files build the same pair. Wired is that pair:

var wired = try nilo.testing.Wired.init(testing.allocator, .{});
defer wired.deinit();

try wired.app.provide(&db);
try wired.app.post("/partners", createPartner);

const answer = try wired.post("/partners", "{\"name\":\"Wati\"}");
try testing.expectEqual(@as(u16, 201), answer.status);

wired.app is a plain App, so routes, services, groups and docs() are registered exactly as they are anywhere else — nothing here is a second API, and no database is assumed. Every Client call is on it without the &app: wired.get, .post, .postWith, .request, .sendRequest, .send, .setHeader, .cookie.

Client is still there and is still the answer when one test needs two of them against one App — two addresses, two cookie jars.

Client.init(gpa, .{ .response_bytes = 1 << 20 }) for a stream that produces a lot — an answer that doesn't fit is truncated rather than failing.

None of this is on the request path, and none of it exists in a running server.

Two things listen() does that the client does not

It checks the services. listen() refuses to open the socket when a route needs a service nobody registered, and names the type and the routes. A test driving the App itself gets a 500 on those routes instead — with the type in the log, since ADR 180, rather than in silence. try app.checkServices(); after the provide calls is the whole gate, and it is worth one line in a test that registers a lot of routes.

It starts the services. A *Db provided to an App has no pool until nilo_start runs, and until then every query answers error.Disconnected. So a test with a database needs the phase:

var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();          // must outlive every query below

try app.provide(&db);
try app.start(threaded.io());     // services checked, pools open, schema checked

app.start also runs db.checking, which is worth having in a test for its own sake: a Row that disagrees with its table passes an entire suite otherwise.

It is for a program that never listens, which a test is. In a program that does, the same work goes in app.before and listen() runs it on its own loop; app.start followed by listen() is refused (ADR 180).

Running the suite

zig build test        # Debug, plus the refusals and every module's gate — the loop
zig build test-all    # the same in ReleaseSafe as well — the gate, and what CI runs

What each costs is measured rather than remembered: bench/result/build.md has the numbers, and the levers when they move.

nilo's own suite runs in both Debug and ReleaseSafe, and -Doptimize= cannot change that. That is not decoration: the bug that made ADR 018 necessary passed 175 tests in Debug and segfaulted in release, because a stack temporary still holds the right bytes until something reuses the stack. A suite that only runs in one mode can't see that class of bug at all.

The two modes are split across two steps only so the fast one can be run without thinking about it. test-all is what CI runs on every push, so nothing reaches main having been checked in one mode — which is the part that matters, and the part that is easy to lose by making it a flag somebody has to remember.

Worth doing the same in your own build.zig if you hold anything across a handler's return: the split costs nothing and the second mode is the only thing that sees a dangling pointer before your users do.