zig-error-patterns
Some patterns I have started to use when writing zig code with unit tests.
Introduction
Although I try to make good use of the debugger, I am quite used to print-based debugging, especially for unit tests. I wanted to explore some tricks to improve print-based debugging, and also incorporate the debugger more.
print-based debugging improved
One big problem with using print debugging is spammy output. If I am running something in a loop, and only one iteration of the loop has anything interesting, I still need to filter and sort through every iteration's output.
Or perhaps I am working with some data structure that has a printable representation that is easier to parse than the raw data; if I don't know where the error comes from I would have to litter print functions everywhere hoping to catch the necessary context.
However, I realized that since zig tests use error
s, instead of panics, it is
possible to use errdefer
to print something only when a test actually fails.
test {
errdefer std.debug.print("{f}", .{ast});
// ...
}
Does a fantastic job of avoiding cluttering the code, and providing the precise context necessary when an error actually occurs.
Running tests in the debugger
If anything more complex is necessary, using the debugger is the way to go.
However, naively trying to run seergdb
or gdb -tui
directly from the
terminal gets difficult, as the test binaries are not in zig-out
directory
(understandably) but in the zig-cache
directory.
I learned a trick from ziggit that the build.zig
can run
commands, and you can feed the artifact path of a build step as an argument into
the command:
// seergdb is a gdb gui frontend
const debugger = b.addSystemCommand(&.{ "seergdb", "--run", "--" });
debugger.addArtifactArg(exe_unit_tests);
const debug_step = b.step("debug", "Run unit tests under debugger");
debug_step.dependOn(&debugger.step);
This makes it really easy to run the proper binary; however, this isn't enough on its own: the debugger only kicks into action for a breakpoint or panic, but the test runner gracefully handles errors.
We can get around this by adding @breakpoint
calls, but then we are
potentially back to square one, where we have to speculatively add breakpoints
and catch the precise moment where things fail, or have to manually step through
until failure.
Combining tricks
Of course, a good solution is to use:
test {
errdefer @breakpoint();
}
This will break the program at the precise moment we hit an error. There's a
chance enough context is still around to snoop at with the debugger, and we
would also have the debug printing that the std.testing.expect{.*}
functions
provide.
However, this does have downsides. Consider the program:
test "errdefer @breakpoint()" {
errdefer @breakpoint();
return error.FixMe;
}
test "normal test" {
return error.FixMe;
}
If we run zig build test
, instead of reporting individual test errors, the
summary will just report that the entire test step failed:
error: while executing test 'main.test.@"errdefer @breakpoint()"',
the following command terminated with signal 5 (expected exited with code 0):
./.zig-cache/o/348ca1907303b41ca8e0fceafd43ad63/test \
--cache-dir=./.zig-cache \
--seed=0xe3f1cf05 --listen=-
If we still want to preserve the normal behavior except when we actually want to break for a debugger, then we need one more trick.
Conditional compilation
The Zig build system allows us use
build options to pass compile time values to our program. We
can leverage these to pass a bool
to decide when to call @breakpoint
in
our tests.
Starting from a simple build script that only runs tests:
1 const std = @import("std");
2
3 pub fn build(b: *std.Build) void {
4 const target = b.standardTargetOptions(.{});
5 const optimize = b.standardOptimizeOption(.{});
6
7 const lib = b.addModule("zig-test-patterns", .{
8 .root_source_file = b.path("src/root.zig"),
9 .target = target,
10 .optimize = optimize,
11 });
12
13 const mod_tests = b.addTest(.{
14 .root_module = lib,
15 });
16
17 const run_mod_tests = b.addRunArtifact(mod_tests);
18
19 const test_step = b.step("test", "Run tests");
20 test_step.dependOn(&run_mod_tests.step);
21 }
We can add the compile time option like so:
4 const options = b.addOptions();
5 options.addOption(bool, "debugger", false);
We need to add the generated options module to our lib:
16 lib.addImport("config", options.createModule());
Then, in our root.zig
file we can reference the options like so:
1 const std = @import("std");
2 const config = @import("config");
3
4 test "errdefer @breakpoint()" {
5 errdefer if (config.debugger) @breakpoint();
6
7 return error.FixMe;
8 }
9
10 test "no breakpoint" {
11 return error.FixMe;
12 }
Now, we can run the test and get no breakpoint:
zig build test
However, we run into a snag: this value is observable to our program, but
we have to recompile the build.zig
in order to change the value. We need
to add the option to the build system itself:
4 var options = b.addOptions();
5 const use_debugger = b.option(
6 bool,
7 "debugger",
8 "Enables code intended to only run under a debugger",
9 ) orelse false;
10 options.addOption(bool, "debugger", use_debugger);
Now we can run:
zig build -Ddebugger test
And observe the change in behavior.
As a final step, we can hook up the command to run our debugger when
the use_debugger
flag is set like so:
29 const test_step = b.step("test", "Run tests");
30 if (use_debugger) {
31 const debugger = b.addSystemCommand(&.{ "seergdb", "--run", "--" });
32 debugger.addArtifactArg(mod_tests);
33
34 test_step.dependOn(&debugger.step);
35 } else {
36 test_step.dependOn(&run_mod_tests.step);
37 }
Now, if we run with the -Ddebugger
flag, the test runner will automatically
run in the debugger, and will break on error! See a screenshot here.
It might be nice to encapsulate the config option checking in a function:
fn breakForDebugger() void {
if (config.debugger) @breakpoint();
}
But I leave that up to your judgement.
Zig Version
This guide was written with Zig version:
0.15.0-dev.1184+c41ac8f19
Also, special thanks to Alanza for reading an early draft of this article.