testing #
written against zig 0.15 and 0.16 (merged).
zig build test only reaches files the test root actually resolves. easy to lose tests silently — and worse than it sounds, see below.
run a single file's tests directly when iterating:
zig test src/foo.zig # runs tests in foo.zig and its imports
zig test src/foo.zig --test-filter "parse" # only tests matching "parse"
test discovery in non-root modules #
re-exporting types is not enough — the rest of the imported file stays dormant:
// in db.zig (transitively imported from main.zig)
pub const Foo = @import("foo.zig").Foo; // pulls in Foo, but NOT foo.zig's `test` blocks
surface them with an explicit test { _ = @import(...) } block somewhere in the test root's analysis graph (typically the bottom of main.zig):
test {
_ = @import("db/Client.zig");
_ = @import("db/zug_conn.zig");
_ = @import("ingest/extractor.zig");
_ = @import("server/search.zig");
}
without this, the tests never execute. zig build test happily reports success when running zero tests.
an unreached file is not compiled either. an earlier version of this note claimed type errors still surface — they don't. zig analyzes function bodies lazily, so a file nothing resolves is never semantically analyzed at all. the langref is exact about it: "test declarations found while resolving the given Zig source file are included."
so an unreached file can hold outright compile errors while CI stays green. found in
zat: oauth/client.zig sat on main formatting a
std.Uri.Component with {s} (no such formatter in 0.16) and passing a const where
deinit wanted *T. two hard errors, 13 dormant tests, green CI — until an outside
contributor tried to actually use the oauth client and the file finally got analyzed.
verify: temporarily break a test (try expectEqual(@as(usize, 999), 0)). if zig build test still passes, your test isn't being discovered.
prefer zig build test --summary all for daily use — it prints N pass (N total) so a zero-discovery regression is visible at a glance.
don't hand-maintain the list — enforce it #
the test { _ = @import(...) } block is a manual index, and manual indexes rot. in zat it
covered repo/* and drifted as five later subsystems were added, hiding 65 tests.
build.zig runs on the host and can read the filesystem, so make the build check itself:
walk src/, find every file declaring a test, and fail the test step for any that the
root's import block doesn't mention.
const test_step = b.step("test", "run unit tests");
test_step.dependOn(&run_tests.step);
if (unreachedTestFiles(b)) |missing| {
test_step.dependOn(&b.addFail(missing).step); // message lists the exact lines to paste
}
0.16 signatures for the walk (everything takes an explicit Io, from b.graph.io):
dir.readFileAlloc(io, sub_path, gpa, limit), dir.openDir(io, path, .{ .iterate = true }),
dir.walk(gpa) then walker.next(io) — but walker.deinit() takes no allocator.
source: zat build.zig — unreachedTestFiles.
multi-module projects need multiple test targets #
_ = @import("...") only descends into files that belong to the same build module as the test root. if build.zig declares another module via b.createModule(...) or the imports slice, you can't reach its files via file-path import — zig errors:
error: file exists in modules 'config' and 'root'
note: files must belong to only one module
solution: add a separate addTest target rooted at the other module's source file, and depend on it from your test step:
const config_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/config.zig"),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(config_tests).step);
zig build test --summary all then reports both runs separately:
+- run test 3 pass (3 total) ← wrapper module
+- run test 4 pass (4 total) ← config module
source: logfire-zig/build.zig
found the hard way in pub-search — five test blocks across extractor.zig and search.zig had been silently skipping for months.
comptime-folding hides the runtime path #
a test with comptime-known inputs can be evaluated at compile time — the function under test never runs at runtime. usually fine, but it bites two ways:
- a logic bug surfaces as a compile error (or vanishes) instead of a runtime test
failure, so
zig test's pass/fail stops being a clean "did the code work" signal — matters when you usezig testas an objective grader (e.g. scoring agent-written code). - runtime-only behavior (safety-checked overflow,
@intCastbounds, runtime UB) isn't exercised at all.
force the inputs to runtime by taking their address — _ = &x makes the compiler treat
x as runtime-known:
test "exercises the runtime path" {
var x: u32 = 200;
_ = &x; // without this, toU8(200) may comptime-fold
try std.testing.expectEqual(@as(u8, 200), toU8(x));
}
caught me when wiring zig test as the oracle for an eval harness (zigman):
a wrong function with literal args got comptime-folded into a compile error rather than
the clean runtime FAIL I needed to score it. routing every oracle input through
var v = …; _ = &v; made the test genuinely run the code.
testing allocator + leaky parsers #
the testing allocator catches leaks. if you use parseFromValueLeaky or similar "leaky" apis, wrap in an arena:
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const result = try leakyFunction(arena.allocator(), input);
expectEqual argument order #
argument order changed from 0.15 — expected first, actual second:
try std.testing.expectEqual(@as(usize, 5), buf.len); // expected, actual
(also covered in the 0.16 migration notes.)
differential clients must build the candidate #
keep release-gating client fixtures beside the library and import the checkout's build module. a benchmark repo pinned to an old archive can stay green while a new SDK release changes behavior. pin the reference implementation instead, and require explicit expected events, errors, cursors, and work counts; two clients agreeing does not prove the contract.
exercise public APIs through bounded loopback HTTP/WebSocket fixtures. an excluded malformed archive row is a useful example: the expected result is wanted rows with no decoding error, while a selected malformed row must still exercise the error callback. pair adversarial cases with controls so a broadly suppressed error cannot make the regression pass.
accept performance samples only after complete delivery and equal-work checks. record the candidate commit, reference revision, compiler, build mode, and CPU count with repeated measurements. a failed semantic case remains a release blocker even if its timings look good. require successful CI for the exact commit before creating its tag; a later tag-triggered run is too late to gate publication.
sources #
- zat — build.zig unreachedTestFiles
- logfire-zig — build.zig, non-root-module test target
- pub-search — silently skipped test blocks in extractor.zig/search.zig
- zigman — zig test as eval-harness oracle
- Jetstream verification harness, 2026-09-20; official Go Jetstream
58c4d7fand Zig SDK v0.1.5 audit.