zig-error-patterns(1)

glfmn.io zig-error-patterns(1)
Name

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.

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 errors, 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:

build.zig
// 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:

build.zig
1const std = @import("std");
2
3pub 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:

build.zig
4const options = b.addOptions();
5options.addOption(bool, "debugger", false);

We need to add the generated options module to our lib:

build.zig
16lib.addImport("config", options.createModule());

Then, in our root.zig file we can reference the options like so:

root.zig
1const std = @import("std");
2const config = @import("config");
3
4test "errdefer @breakpoint()" {
5 errdefer if (config.debugger) @breakpoint();
6
7 return error.FixMe;
8}
9
10test "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:

build.zig
4var options = b.addOptions();
5const use_debugger = b.option(
6 bool,
7 "debugger",
8 "Enables code intended to only run under a debugger",
9) orelse false;
10options.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:

build.zig
29const test_step = b.step("test", "Run tests");
30if (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.