Zig 0.17.0

Testing

bash
zig build test

It runs every test of the project. A new project has a few already, and this page shows how to add the most useful kind: tests that send requests to the app.

The code on this page is from Posts, the example app in examples/posts (source).

What a new project checks

  • The conventions. The same report as spider check: routes in the right file, templates using the project's classes.
  • The route table of each feature. spider g feature writes it next to the routes.
  • The app answers. One request to the running app, described below.
  • That no test file is left out. A file with tests that nothing imports fails the build, with its name.

The route table is a list of method, path and who may call the route, as spider routes prints it. This is the one of the posts feature:

zig
test "posts routes: method, path and access" {
    try spider.testing.expectRoutes(routes.build(), &.{
        .{ "GET", "/posts", "public" },
        .{ "GET", "/posts/new", "authenticated" },
        .{ "GET", "/posts/export.csv", "public" },
        .{ "POST", "/posts/create", "authenticated" },
        .{ "GET", "/posts/:id", "public" },
        .{ "GET", "/posts/:id/edit", "policy:post.update" },
        .{ "POST", "/posts/:id/update", "policy:post.update" },
        .{ "GET", "/posts/:id/delete", "policy:post.delete" },
        .{ "POST", "/posts/:id/delete", "policy:post.delete" },
    });
}

Add a route, or change who may call one, and the test fails until the table says the same. It prints the table as it is now, ready to paste.

Requests against the app

A new project also has src/app_test.zig, with a function called run and a first test. run does what main does to start the server, with one difference: the database is a file of its own, so the tests never touch db.sqlite.

zig
const scratch_db = ".zig-cache/posts-test.sqlite";

/// What main() does, on the scratch database.
fn run() !void {
    const gpa = std.heap.smp_allocator;
    var threaded: std.Io.Threaded = .init(gpa, .{});
    const io = threaded.io();

    std.Io.Dir.cwd().deleteFile(io, scratch_db) catch {};
    try db.init(gpa, io, .{ .path = scratch_db });
    try core.db.migrations.migrate(gpa);
    try main.serve(gpa, io);
}

main.serve is the function in src/main.zig that builds the server and listens.

A test asks for the app with spider.testing.start(run). It runs the real server, on a free port, once for all the tests of the project: every test calls it and gets the same one. Nothing is faked: middlewares, templates, the error handler and the database all take part.

zig
test "GET /posts lists the posts, newest first" {
    const app = try spider.testing.start(run);
    var res = try app.get("/posts");
    defer res.deinit();
    try res.expectStatus(200);
    try res.expectContains("Latest posts");

    const newest = std.mem.indexOf(u8, res.body, "Routes that read like a sentence").?;
    const oldest = std.mem.indexOf(u8, res.body, "Running Spider in a tiny container").?;
    try std.testing.expect(newest < oldest);
    try res.expectContains("2 hours ago");
    try res.expectContains("Last week");
}

app.get sends a request, and what comes back has the status, the headers and the body, with a few checks ready: expectStatus, expectContains.

The tests share one database, and they run in any order. Make each test create what it needs, and do not count on a table being empty.

Forms and redirects

postForm sends a struct as a form. A redirect is not followed: the response is the redirect itself, and you ask for the next page.

Posts sends a visitor who is not signed in to the sign-in page, whatever they try to write:

zig
test "writing needs a login: a visitor is sent to the sign-in page" {
    const app = try spider.testing.start(run);

    for ([_][]const u8{ "/posts/new", "/posts/4/edit", "/posts/4/delete" }) |target| {
        var res = try app.get(target);
        defer res.deinit();
        try res.expectRedirect("/auth/login");
    }

    var create = try app.postForm("/posts/create", .{ .title = "No session", .body = "x" });
    defer create.deinit();
    try create.expectRedirect("/auth/login");

    var delete = try app.request(.{ .method = "POST", .target = "/posts/4/delete" });
    defer delete.deinit();
    try delete.expectRedirect("/auth/login");

    // And nothing was written or removed.
    var feed = try app.get("/posts");
    defer feed.deinit();
    try feed.expectNotContains("No session");
    try feed.expectContains("Routes that read like a sentence");
}

A signed-in visitor

To test what needs a login, sign up in the test and keep the cookie. This helper comes with spider g auth:

zig
/// Creates an account and gives back the Cookie header of its session.
fn signUp(app: spider.testing.App, buf: []u8, name: []const u8, email: []const u8) ![]const u8 {
    var res = try app.postForm("/auth/register", .{ .name = name, .email = email, .password = "a good password" });
    defer res.deinit();
    try res.expectRedirect("/");
    return std.fmt.bufPrint(buf, "Cookie: session={s}", .{res.cookie("session").?});
}

app.with gives the same app sending those headers on every request, so the rest of the test reads like the visitor using the site:

zig
test "POST /posts/create saves the post under the name of who is signed in" {
    const app = try spider.testing.start(run);
    var buf: [1024]u8 = undefined;
    const writer = app.with(&.{try signUp(app, &buf, "Zé; da Silva", "ze@example.com")});

    // The form has no author field, and one sent anyway is ignored.
    var res = try writer.postForm("/posts/create", .{ .title = "Hello from the test", .body = "A **bold** start.", .author = "Someone Else" });
    defer res.deinit();
    try res.expectStatus(303);

    var post = try app.get(res.header("Location").?);
    defer post.deinit();
    try post.expectContains("Hello from the test");
    try post.expectContains("A <b>bold</b> start.");
    try post.expectContains("Zé; da Silva");
    try post.expectNotContains("Someone Else");
    try post.expectContains("Just now");
}

Headers and JSON

request takes the method, header lines and a body, for everything the shortcuts do not cover. The API of Posts wants a key in a header and a JSON body:

zig
const key = "X-Api-Key: dev-key";

test "POST /api/posts saves a post sent as JSON" {
    const app = try spider.testing.start(run);
    const json = &.{ key, "Content-Type: application/json" };

    var saved = try app.request(.{
        .method = "POST",
        .target = "/api/posts",
        .headers = json,
        .body = "{\"title\":\"From the API\",\"body\":\"Sent as JSON.\",\"author\":\"A program\"}",
    });
    defer saved.deinit();
    try saved.expectStatus(201);
    const location = saved.header("Location").?;
    try std.testing.expect(std.mem.startsWith(u8, location, "/api/posts/"));

    var back = try app.request(.{ .target = location, .headers = &.{key} });
    defer back.deinit();
    try back.expectContains("\"title\":\"From the API\"");

    var empty = try app.request(.{ .method = "POST", .target = "/api/posts", .headers = json, .body = "{\"title\":\"Only a title\"}" });
    defer empty.deinit();
    try empty.expectStatus(422);
    try empty.expectContains("\"body\":\"Write something in the body.\"");

    var not_json = try app.request(.{ .method = "POST", .target = "/api/posts", .headers = json, .body = "title=x" });
    defer not_json.deinit();
    try not_json.expectStatus(400);
}

What a response has

  • res.status, res.body
  • res.header("Location") and res.cookie("session"): the value, or null
  • expectStatus, expectContains, expectNotContains, expectHeader, expectRedirect

A failed expectation prints the request and the start of the body, so you see what the server answered.

Other things to test

The same app reaches everything the server does. Where each kind is shown:

  • A database of its own. With SQLite, a file the tests create, as run does above. With PostgreSQL, a server that exists for one run, migrated by main.migrate as main does: PostgreSQL has the script, Migrations in production tests of the migrations themselves.
  • A module. server.check(arena, io) gives the report listen() acts on, on a server that never listens; the module's routes and jobs answer through app like the app's own: Modules.
  • Mail. The tests send to memory and read what was sent: Mail.
  • What the app asks of other servers. A test starts a small server of its own and points the app at it: HTTP client.
  • A stream of events. The test opens the stream and reads what arrives: Live pages.
  • A WebSocket. WebSocket has a client of a few lines.
  • An upload. A multipart body written by hand: Uploads and downloads.
  • A request htmx made. The same request with the HX-Request: true header: htmx.
  • What a proxy adds. X-Forwarded-For, X-Forwarded-Proto and the rest are header lines: Security in production.

When a test seems not to run

  • Nothing changed, so nothing ran. zig build test keeps the result of a run that passed and does not repeat it while the code is the same. A database or a file the tests read is not part of that. zig build test --summary all says cached beside what it did not run again; change a source file to run it.
  • The file is not part of the tests. A new file with tests has to be imported from a file that already is. A project made by spider new fails the build, naming the file, when one is left out.
  • The code logs an error. A test fails when what it runs logs at .err, even with every check passing ("errors were logged"). Code that fails on purpose in a test logs at .warn.