Skip to content

testing

nate
Jul 8, 2026 · 3 min read · 1 read

testing

ritten against zig 0.15 and 0.16 (merged).

zig build test reliably compiles test blocks, but only runs the ones the test root actually analyzes. easy to lose tests silently.

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, tests compile (so type errors still surface) but never execute. zig build test happily reports success when running zero tests.

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.

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 use zig test as an objective grader (e.g. scoring agent-written code).
  • runtime-only behavior (safety-checked overflow, @intCast bounds, 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.)

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions