# 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: ```bash 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: ```zig // 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`): ```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](https://tangled.org/zat.dev/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. ```zig 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](https://tangled.org/zat.dev/zat) — `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: ```zig 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](https://tangled.sh/@zzstoatzz.io/logfire-zig) found the hard way in [pub-search](https://tangled.sh/@zzstoatzz.io/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: ```zig 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](https://tangled.org/zzstoatzz.io/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: ```zig 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: ```zig try std.testing.expectEqual(@as(usize, 5), buf.len); // expected, actual ``` (also covered in [the 0.16 migration notes](versions/migration-0.16.md#stdtestingexpectequal).) ## 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](https://tangled.org/zat.dev/jetstream/tree/32fee1f2f676637f90ca34296110e976fc3563aa/checks), 2026-09-20; official Go Jetstream `58c4d7f` and Zig SDK v0.1.5 audit.