I’m currently venturing into exotic testing territory with a firmware project I’m working on. Generally speaking, I have a board I can communicate with from my laptop via a USB to serial adapter.
A simplified view of my setup is something like:
pub const serial_adapter_namespace = struct {
pub fn instance() *SerialAdapter {
if (_inst) |*v| return v else std.debug.panic("Trying to use SerialAdapter instance without calling init() first", .{});
}
// The use of an optional type here w/ accessor function avoids initialization order footguns
var _inst: ?SerialAdapter = null;
pub fn init() void {
_inst = someInitializationFunction();
}
};
Where someInitializationFunction
basically just configures the serial port how I need it to be.
I’ve set up a very simple custom test runner that initializes my serial port before calling any of the test functions:
const std = @import("std");
const builtin = @import("builtin");
const serial_adapter = @import("serial_adapter.zig").serial_adapter_namespace;
pub const std_options: std.Options = .{
.log_level = .info,
};
pub fn main() !void {
serial_adapter.init();
const out = std.io.getStdOut().writer();
for (builtin.test_functions) |t| {
const start = std.time.milliTimestamp();
const result = t.func();
const elapsed = std.time.milliTimestamp() - start;
const name = extractName(t);
if (result) |_| {
try std.fmt.format(out, "{s} passed - ({d}ms)\n", .{ name, elapsed });
} else |err| {
try std.fmt.format(out, "{s} failed - {}\n", .{ t.name, err });
std.process.exit(1);
}
}
}
fn extractName(t: std.builtin.TestFn) []const u8 {
const marker = std.mem.lastIndexOf(u8, t.name, ".test.") orelse return t.name;
return t.name[marker + 6 ..];
}
And an additional file containing the actual tests:
test "firstTest" {
// Shouldn't be necessary?
// serial_adapter.init();
try std.testing.expect(try serial_adapter.instance().doSomething());
}
test "another" {
// If the first test calls this, then it isn't necessary
// serial_adapter.init();
try std.testing.expect(try serial_adapter.instance().moduleCheck());
}
Running this test I hit my panic statement that checks if the serial port variable has been initialized:
if (_inst) |*v| return v else std.debug.panic("Trying to use SerialAdapter instance without calling init() first", .{});
Thinking about this more, I have a feeling my test runner is an executable compiled separately from the tests themselves, and thus doesn’t share a global scope, is this correct? Jamming the redundant initialization call into the first test that runs initializes the variable for the remainder of the tests, but I’d like to understand exactly what’s going on under the hood a little more to figure out how to do this cleanly.
Reading this post on a custom test runner I can start to understand their somewhat hacky solution for creating an “intialization” and “deinitialization” that runs before/after all tests based on a naming scheme.
Is there a better way to do what I’m after? Having a setup function that runs before all my tests will become even more critical when I ideally eventually run Zig’s test runner on target rather than on my laptop.