Zig 0.17.0

testing

On this page:

  • spider.testing: Test helpers for apps: start (requests against the running app), expectRoutes, expectConventions, expectAllTestsDiscovered.
  • spider.conventions: App conventions checked by spider check and testing.expectConventions().

spider.testing

Test helpers for apps (spider.testing): requests against the running app (start), a feature's access table (expectRoutes), the app's conventions (expectConventions), and the check that catches test files zig build test silently skips (expectAllTestsDiscovered).

Zig only compiles the test blocks of files it analyzes as a whole from the test binary's root module (the root file, and files imported from a test {} block or through std.testing.refAllDecls, which goes one level deep). A file with tests that isn't reached that way is skipped without a word, and the step still reports success.

Usage (see testManifest in Spider's build.zig):

zig
// build.zig
const spider_build = @import("spider");
features_mod.addImport("test_manifest", spider_build.testManifest(b, "src/features"));

// src/features/mod.zig (the module's root file)
test "every test file is part of this test binary" {
    try spider.testing.expectAllTestsDiscovered(@import("test_manifest"));
}

spider.testing.conventions

The app conventions behind expectConventions() and spider check.

testing.conventions.Severity

zig
pub const Severity = enum { err, warn }

How serious an issue is. err fails spider check and spider.testing.expectConventions(); warn is only listed, and fails spider check --strict.

testing.conventions.Issue

zig
pub const Issue = struct

One broken convention: where it is, which rule, and how to fix it.

Fields

  • severity: Severity
  • path: []const u8: Relative to the project root.
  • line: usize: Line number in the file, starting at 1.
  • rule: []const u8: The name of the rule, as listed at the top of this file (kit-class, route-access, ...).
  • message: []const u8: What is wrong, in a sentence.
  • fix: []const u8: What to do about it.

testing.conventions.Report

zig
pub const Report = struct

The issues found by a check. Start with .{ .arena = arena }; everything it holds is allocated there.

zig
var report: conventions.Report = .{ .arena = arena.allocator() };
try conventions.check(io, arena.allocator(), root, &report);

Fields

  • arena: std.mem.Allocator: Where the issues and their texts are allocated.
  • issues: std.ArrayListUnmanaged(Issue) = .empty: The issues, in the order they were found.

testing.conventions.Report.add

zig
pub fn add(r: *Report, severity: Severity, path: []const u8, line: usize, rule: []const u8, comptime msg_fmt: []const u8, msg_args: anytype, fix: []const u8) !void

Adds an issue. path is copied and the message is formatted into the report's arena; rule and fix are kept as given.

testing.conventions.Report.count

zig
pub fn count(r: Report, severity: Severity) usize

Number of issues of the given severity.

testing.conventions.Report.write

zig
pub fn write(r: Report, w: *std.Io.Writer) !void

Writes each issue as "src/x.html:12: error [kit-class: ...\n fix: ...", errors first, then a line with the two counts.

testing.conventions.check

zig
pub fn check(io: std.Io, arena: std.mem.Allocator, root: std.Io.Dir, report: *Report) !void

Checks the app whose project directory is root: every .zig and .html file under src/, and the features under src/features/. Issues are added to report; an app without a src/ directory adds none. The route-access rule runs only when src/main.zig shows an auth middleware. Fails only on an allocation or a directory read error.

testing.conventions.checkRoutesOutside

zig
pub fn checkRoutesOutside(report: *Report, path: []const u8, text: []const u8) !void

Reports each .get("/path", ...) & co. in text (rule route-outside-routes): for the .zig files that are not a routes.zig. c.params.get("id") and friends don't start their string with '/', so they don't match; neither do commented-out lines.

testing.conventions.checkRouteAccess

zig
pub fn checkRouteAccess(report: *Report, path: []const u8, text: []const u8) !void

With auth, every route in a routes.zig must say who may call it: in its own config, or through the defaults() of its group. text is the file, path the name reported. A plain .sse( or .ws( route is always reported: it takes no config. The check reads the source as text; spider routes --check checks the running app exactly.

testing.conventions.isKitClass

zig
pub fn isKitClass(token: []const u8) bool

A component class of the daisyUI kit (btn, btn-primary, card-body, ...) — templates use ui-* classes instead. Tailwind utilities sharing a word (select-none, table, collapse) aren't.

testing.conventions.checkTemplate

zig
pub fn checkTemplate(report: *Report, path: []const u8, text: []const u8, styles: []const u8, has_ui_layer: bool) !void

The template rules for one file: text is the template, path the name reported. styles is the content of src/styles.css (which icon sets it loads); has_ui_layer says whether the app has a src/ui.css, without which the kit-class rule and the ti icon rule do not apply.

spider.testing.http

Requests against the running app, in a test: see testing/http.zig.

zig
const app = try spider.testing.start(run);
var res = try app.get("/posts");
defer res.deinit();
try res.expectStatus(200);

testing.http.App

zig
pub const App = struct

A server started by start, ready to receive requests.

Fields

  • port: u16
  • headers: []const []const u8 = &.{}: Header lines sent with every request (see with).

testing.http.App.with

zig
pub fn with(self: App, headers: []const []const u8) App

The same app, sending headers with every request: a signed-in visitor is app.with(&.{"Cookie: session=..."}). The slice must outlive the requests made with it.

testing.http.App.get

zig
pub fn get(self: App, target: []const u8) !Response

GETs target (the path, with its query string if any). Free the response with deinit.

testing.http.App.postForm

zig
pub fn postForm(self: App, target: []const u8, fields: anytype) !Response

POSTs fields as an HTML form does (urlencoded). fields is a struct of texts, numbers and booleans; an optional field that is null is left out.

testing.http.App.postJson

zig
pub fn postJson(self: App, target: []const u8, json: []const u8) !Response

POSTs json (the text of a JSON document).

testing.http.App.request

zig
pub fn request(self: App, options: Request) !Response

Any request: the method, extra header lines ("Cookie: a=b"), a body. Redirects are not followed: the answer is the 3xx itself. Each request opens a connection of its own and sends Connection: close. Fails with the connection's error, error.MalformedResponse for an answer that is not HTTP, or error.StreamTooLong for one over 16 MiB.

testing.http.Request

zig
pub const Request = struct

The options of App.request. Only target is required.

Fields

  • method: []const u8 = "GET"
  • target: []const u8
  • headers: []const []const u8 = &.{}: Extra header lines, each without its line ending: "Cookie: a=b".
  • body: ?[]const u8 = null

testing.http.Response

zig
pub const Response = struct

What the server answered. Free it with deinit.

Fields

  • status: u16
  • head: []const u8: The status line and the headers, as sent.
  • body: []const u8
  • method: []const u8
  • target: []const u8
  • arena: std.heap.ArenaAllocator

testing.http.Response.deinit

zig
pub fn deinit(self: *Response) void

Frees the response: head, body and what header and cookie returned.

testing.http.Response.header

zig
pub fn header(self: Response, name: []const u8) ?[]const u8

The first header named name (any letter case), or null.

zig
pub fn cookie(self: Response, name: []const u8) ?[]const u8

The value of the cookie name among the Set-Cookie headers (as sent, not decoded), or null.

testing.http.Response.expectStatus

zig
pub fn expectStatus(self: Response, expected: u16) !void

Fails with error.TestUnexpectedStatus when the status is not expected, after printing the request and the start of the body.

testing.http.Response.expectContains

zig
pub fn expectContains(self: Response, text: []const u8) !void

Fails with error.TestExpectedText when the body does not contain text.

testing.http.Response.expectNotContains

zig
pub fn expectNotContains(self: Response, text: []const u8) !void

Fails with error.TestUnexpectedText when the body contains text.

testing.http.Response.expectHeader

zig
pub fn expectHeader(self: Response, name: []const u8, expected: []const u8) !void

Fails with error.TestExpectedHeader when the response has no header name or its value is not exactly expected.

testing.http.Response.expectRedirect

zig
pub fn expectRedirect(self: Response, to: []const u8) !void

Expects a 3xx answer whose Location is exactly to. Fails with error.TestExpectedRedirect for another status, or error.TestExpectedHeader for another Location.

testing.http.start

zig
pub fn start(comptime run: fn () anyerror!void) !App

Starts the app once and gives back where to send requests. run is what main() does to serve: it builds the server and calls listen(), which here takes a free port on 127.0.0.1 whatever port the app asks for. Every test of the binary shares the one server; later calls with the same run return it. The server runs on a thread of its own until the test binary exits.

Fails with the error run returned, or error.ServerDidNotStart when nothing accepts connections after about 10 seconds.

testing.http.formBody

zig
pub fn formBody(arena: std.mem.Allocator, fields: anytype) ![]const u8

fields as application/x-www-form-urlencoded: text fields, numbers and booleans; an optional field that is null is left out.

spider.testing.start

zig
pub fn start(comptime run: fn () anyerror!void) !App

Starts the app once and gives back where to send requests. run is what main() does to serve: it builds the server and calls listen(), which here takes a free port on 127.0.0.1 whatever port the app asks for. Every test of the binary shares the one server; later calls with the same run return it. The server runs on a thread of its own until the test binary exits.

Fails with the error run returned, or error.ServerDidNotStart when nothing accepts connections after about 10 seconds.

spider.testing.App

zig
pub const App = struct

A server started by start, ready to receive requests.

Fields

  • port: u16
  • headers: []const []const u8 = &.{}: Header lines sent with every request (see with).

testing.App.with

zig
pub fn with(self: App, headers: []const []const u8) App

The same app, sending headers with every request: a signed-in visitor is app.with(&.{"Cookie: session=..."}). The slice must outlive the requests made with it.

testing.App.get

zig
pub fn get(self: App, target: []const u8) !Response

GETs target (the path, with its query string if any). Free the response with deinit.

testing.App.postForm

zig
pub fn postForm(self: App, target: []const u8, fields: anytype) !Response

POSTs fields as an HTML form does (urlencoded). fields is a struct of texts, numbers and booleans; an optional field that is null is left out.

testing.App.postJson

zig
pub fn postJson(self: App, target: []const u8, json: []const u8) !Response

POSTs json (the text of a JSON document).

testing.App.request

zig
pub fn request(self: App, options: Request) !Response

Any request: the method, extra header lines ("Cookie: a=b"), a body. Redirects are not followed: the answer is the 3xx itself. Each request opens a connection of its own and sends Connection: close. Fails with the connection's error, error.MalformedResponse for an answer that is not HTTP, or error.StreamTooLong for one over 16 MiB.

spider.testing.Response

zig
pub const Response = struct

What the server answered. Free it with deinit.

Fields

  • status: u16
  • head: []const u8: The status line and the headers, as sent.
  • body: []const u8
  • method: []const u8
  • target: []const u8
  • arena: std.heap.ArenaAllocator

testing.Response.deinit

zig
pub fn deinit(self: *Response) void

Frees the response: head, body and what header and cookie returned.

testing.Response.header

zig
pub fn header(self: Response, name: []const u8) ?[]const u8

The first header named name (any letter case), or null.

zig
pub fn cookie(self: Response, name: []const u8) ?[]const u8

The value of the cookie name among the Set-Cookie headers (as sent, not decoded), or null.

testing.Response.expectStatus

zig
pub fn expectStatus(self: Response, expected: u16) !void

Fails with error.TestUnexpectedStatus when the status is not expected, after printing the request and the start of the body.

testing.Response.expectContains

zig
pub fn expectContains(self: Response, text: []const u8) !void

Fails with error.TestExpectedText when the body does not contain text.

testing.Response.expectNotContains

zig
pub fn expectNotContains(self: Response, text: []const u8) !void

Fails with error.TestUnexpectedText when the body contains text.

testing.Response.expectHeader

zig
pub fn expectHeader(self: Response, name: []const u8, expected: []const u8) !void

Fails with error.TestExpectedHeader when the response has no header name or its value is not exactly expected.

testing.Response.expectRedirect

zig
pub fn expectRedirect(self: Response, to: []const u8) !void

Expects a 3xx answer whose Location is exactly to. Fails with error.TestExpectedRedirect for another status, or error.TestExpectedHeader for another Location.

spider.testing.expectRoutes

zig
pub fn expectRoutes(group: Group, expected: []const Row) !void

Checks that group registers exactly the routes of expected, each with that access; order does not matter. The access column is "public", "roles:a,b", "org:a,b", "org:a roles:b", "authenticated" or "-" (nothing declared), followed by " policy:name" when the route has a policy.

zig
test "posts routes: method, path and access" {
    try spider.testing.expectRoutes(routes.build(), &.{
        .{ "GET", "/posts", "roles:editor" },
        .{ "POST", "/posts/:id/delete", "roles:admin" },
    });
}

On a difference it prints the routes missing and the routes not expected, then the actual table ready to paste, and fails with error.RoutesDiffer.

spider.testing.expectConventions

zig
pub fn expectConventions() !void

The app follows Spider's conventions (conventions.zig): fails with error.ConventionsViolated, listing each problem with its file, line and fix — the same report as spider check. Warnings alone do not fail it. Generated apps call it from a test in src/main.zig. Finds the project root by walking up from the working directory to build.zig.zon (error.ProjectRootNotFound when none is found 20 levels up). For tests only: it uses std.testing.

spider.testing.expectAllTestsDiscovered

zig
pub fn expectAllTestsDiscovered(comptime manifest: type) !void

manifest is the module generated by testManifest(): parallel lists of the files under the module's root directory that declare test "..." blocks (paths) and the name prefix Zig gives their tests (prefixes). Fails with error.TestFilesNotDiscovered, naming each file, when one of them has no test in this binary.

spider.testing.prefixFor

zig
pub fn prefixFor(buf: []u8, rel_path: []const u8) []const u8

Test-name prefix Zig uses for a file at rel_path (relative to the module's root directory): "posts/model.zig" -> "posts.model". Written into buf, and cut to its length.

spider.testing.declaresTests

zig
pub fn declaresTests(source: []const u8) bool

Whether Zig source text declares a named test (test "..." at the start of a line).

spider.conventions

A Spider app's conventions, checked — so code that "looks like it works" but routes, styles or protects things the wrong way fails with a file, a line and the fix, instead of relying on everyone (or every coding agent) having read the docs.

Used by spider check and, in generated apps, by a test in src/main.zig (spider.testing.expectConventions()), so zig build test enforces it.

Errors: route-outside-routes a route registered outside src/features/<f>/routes.zig kit-class a UI kit class (btn, card, ...) in a template instead of ui-* icon-set an icon class from a set src/styles.css doesn't load bool-attr disabled/checked/selected/... given "{ expr }" (on even when false) feature-not-registered a feature folder missing from src/features/mod.zig route-access with auth, a route that declares no access (roles / org_roles / public / authenticated / policy, itself or through its group's defaults()) Warnings: inline-style style="..." in a template (display:none for Alpine is fine) inline-svg <svg> pasted in a template instead of an icon class routes-test a feature with routes.zig but no routes_test.zig

spider.conventions.Severity

zig
pub const Severity = enum { err, warn }

How serious an issue is. err fails spider check and spider.testing.expectConventions(); warn is only listed, and fails spider check --strict.

spider.conventions.Issue

zig
pub const Issue = struct

One broken convention: where it is, which rule, and how to fix it.

Fields

  • severity: Severity
  • path: []const u8: Relative to the project root.
  • line: usize: Line number in the file, starting at 1.
  • rule: []const u8: The name of the rule, as listed at the top of this file (kit-class, route-access, ...).
  • message: []const u8: What is wrong, in a sentence.
  • fix: []const u8: What to do about it.

spider.conventions.Report

zig
pub const Report = struct

The issues found by a check. Start with .{ .arena = arena }; everything it holds is allocated there.

zig
var report: conventions.Report = .{ .arena = arena.allocator() };
try conventions.check(io, arena.allocator(), root, &report);

Fields

  • arena: std.mem.Allocator: Where the issues and their texts are allocated.
  • issues: std.ArrayListUnmanaged(Issue) = .empty: The issues, in the order they were found.

conventions.Report.add

zig
pub fn add(r: *Report, severity: Severity, path: []const u8, line: usize, rule: []const u8, comptime msg_fmt: []const u8, msg_args: anytype, fix: []const u8) !void

Adds an issue. path is copied and the message is formatted into the report's arena; rule and fix are kept as given.

conventions.Report.count

zig
pub fn count(r: Report, severity: Severity) usize

Number of issues of the given severity.

conventions.Report.write

zig
pub fn write(r: Report, w: *std.Io.Writer) !void

Writes each issue as "src/x.html:12: error [kit-class: ...\n fix: ...", errors first, then a line with the two counts.

spider.conventions.check

zig
pub fn check(io: std.Io, arena: std.mem.Allocator, root: std.Io.Dir, report: *Report) !void

Checks the app whose project directory is root: every .zig and .html file under src/, and the features under src/features/. Issues are added to report; an app without a src/ directory adds none. The route-access rule runs only when src/main.zig shows an auth middleware. Fails only on an allocation or a directory read error.

spider.conventions.checkRoutesOutside

zig
pub fn checkRoutesOutside(report: *Report, path: []const u8, text: []const u8) !void

Reports each .get("/path", ...) & co. in text (rule route-outside-routes): for the .zig files that are not a routes.zig. c.params.get("id") and friends don't start their string with '/', so they don't match; neither do commented-out lines.

spider.conventions.checkRouteAccess

zig
pub fn checkRouteAccess(report: *Report, path: []const u8, text: []const u8) !void

With auth, every route in a routes.zig must say who may call it: in its own config, or through the defaults() of its group. text is the file, path the name reported. A plain .sse( or .ws( route is always reported: it takes no config. The check reads the source as text; spider routes --check checks the running app exactly.

spider.conventions.isKitClass

zig
pub fn isKitClass(token: []const u8) bool

A component class of the daisyUI kit (btn, btn-primary, card-body, ...) — templates use ui-* classes instead. Tailwind utilities sharing a word (select-none, table, collapse) aren't.

spider.conventions.checkTemplate

zig
pub fn checkTemplate(report: *Report, path: []const u8, text: []const u8, styles: []const u8, has_ui_layer: bool) !void

The template rules for one file: text is the template, path the name reported. styles is the content of src/styles.css (which icon sets it loads); has_ui_layer says whether the app has a src/ui.css, without which the kit-class rule and the ti icon rule do not apply.