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 checkand 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):
// 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
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
pub const Issue = struct
One broken convention: where it is, which rule, and how to fix it.
Fields
severity: Severitypath: []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
pub const Report = struct
The issues found by a check. Start with .{ .arena = arena }; everything it holds is allocated there.
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
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
pub fn count(r: Report, severity: Severity) usize
Number of issues of the given severity.
testing.conventions.Report.write
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
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
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
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
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
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.
const app = try spider.testing.start(run);
var res = try app.get("/posts");
defer res.deinit();
try res.expectStatus(200);
testing.http.App
pub const App = struct
A server started by start, ready to receive requests.
Fields
port: u16headers: []const []const u8 = &.{}: Header lines sent with every request (seewith).
testing.http.App.with
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
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
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
pub fn postJson(self: App, target: []const u8, json: []const u8) !Response
POSTs json (the text of a JSON document).
testing.http.App.request
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
pub const Request = struct
The options of App.request. Only target is required.
Fields
method: []const u8 = "GET"target: []const u8headers: []const []const u8 = &.{}: Extra header lines, each without its line ending: "Cookie: a=b".body: ?[]const u8 = null
testing.http.Response
pub const Response = struct
What the server answered. Free it with deinit.
Fields
status: u16head: []const u8: The status line and the headers, as sent.body: []const u8method: []const u8target: []const u8arena: std.heap.ArenaAllocator
testing.http.Response.deinit
pub fn deinit(self: *Response) void
Frees the response: head, body and what header and cookie returned.
testing.http.Response.header
pub fn header(self: Response, name: []const u8) ?[]const u8
The first header named name (any letter case), or null.
testing.http.Response.cookie
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
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
pub fn expectContains(self: Response, text: []const u8) !void
Fails with error.TestExpectedText when the body does not contain text.
testing.http.Response.expectNotContains
pub fn expectNotContains(self: Response, text: []const u8) !void
Fails with error.TestUnexpectedText when the body contains text.
testing.http.Response.expectHeader
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
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
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
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
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
pub const App = struct
A server started by start, ready to receive requests.
Fields
port: u16headers: []const []const u8 = &.{}: Header lines sent with every request (seewith).
testing.App.with
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
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
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
pub fn postJson(self: App, target: []const u8, json: []const u8) !Response
POSTs json (the text of a JSON document).
testing.App.request
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
pub const Response = struct
What the server answered. Free it with deinit.
Fields
status: u16head: []const u8: The status line and the headers, as sent.body: []const u8method: []const u8target: []const u8arena: std.heap.ArenaAllocator
testing.Response.deinit
pub fn deinit(self: *Response) void
Frees the response: head, body and what header and cookie returned.
testing.Response.header
pub fn header(self: Response, name: []const u8) ?[]const u8
The first header named name (any letter case), or null.
testing.Response.cookie
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
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
pub fn expectContains(self: Response, text: []const u8) !void
Fails with error.TestExpectedText when the body does not contain text.
testing.Response.expectNotContains
pub fn expectNotContains(self: Response, text: []const u8) !void
Fails with error.TestUnexpectedText when the body contains text.
testing.Response.expectHeader
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
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
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.
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
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
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
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
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
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
pub const Issue = struct
One broken convention: where it is, which rule, and how to fix it.
Fields
severity: Severitypath: []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
pub const Report = struct
The issues found by a check. Start with .{ .arena = arena }; everything it holds is allocated there.
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
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
pub fn count(r: Report, severity: Severity) usize
Number of issues of the given severity.
conventions.Report.write
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
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
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
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
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
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.