Let’s assume I have a packed struct that must be exactly 12 bytes, and I need to mock the getCalcSize()
method to return different values in production vs tests. I’m considering these approaches:
Approach 1: Using global var
var test_calc_size: u64 = 8;
pub const MyStruct = packed struct {
// fields...
pub fn getCalcSize(self: *const MyStruct) u64 {
return if (!builtin.is_test)
self.calculateProductionSize()
else
test_calc_size;
}
};
Pros:
Better encapsulation of test value
Controlled access to test value
Clear API for testing
Cons:
It uses global state
Larger binary size
Additional function overhead
Approach 2: Using builtin.is_test directly
pub const MyStruct = packed struct {
// fields...
pub fn getCalcSize(self: *const MyStruct) u64 {
return if (!builtin.is_test)
self.calculateProductionSize()
else
8;
}
};
Pros:
Simple implementation
No global state
Doesn’t affect struct size
Comptime evaluation
Cons:
Can’t control test value from test code
Hard-coded test value
Less flexible for different test scenarios
Approach 3: Using function pointer in struct (can;t be field in packed struct)
pub const MyStruct = packed struct {
var calc_size_fn: *const fn(*const MyStruct) u64 = defaultCalcSize;
// fields...
fn defaultCalcSize(self: *const MyStruct) u64 {
return self.calculateProductionSize();
}
pub fn getCalcSize(self: *const MyStruct) u64 {
return calc_size_fn(self);
}
};
Pros:
Full control over test logic
Can have different implementations for tests
Clear separation of concerns
Function pointer scoped to struct
Cons:
Still uses global state (but scoped to struct)
Larger binary size
Need to manage function pointer state
I need to keep the struct size exactly 12 bytes, so I can’t add any fields for testing purposes. I could also use generic struct to inject the function, but that would complicate the whole solution just for one test case, which seems like overengineering.
You could also scope your test_size to the struct, this doesn’t change its size:
const std = @import("std");
const test1 = struct {
a: u64,
b: u64,
};
const test2 = struct {
var my_additional_size: usize = 8;
a: u64,
b: u64,
};
pub fn main() void {
std.debug.print("{}\n", .{@sizeOf(test1)});
std.debug.print("{}\n", .{@sizeOf(test2)});
std.debug.print("{}\n", .{test2.my_additional_size});
// Access and change the scoped declaration:
test2.my_additional_size = 12;
std.debug.print("{}\n", .{test2.my_additional_size});
}
Sure, it’s a mutation of Approach 1 (which I do not like ;)), and it still has the same side effect (larger binary size).
Edited: But I’m a simple guy, if it’s a suggested solution, I would like it
n0s4
February 5, 2025, 3:38pm
4
If it’s only being referenced in branches where buitin.is_test
is true, then i’d be surprised if it affected your binary size outside of tests.
The compiler is lazy in that it won’t even ‘look at’ my_additional_size
if it isn’t referenced. And it won’t ever ‘look at’ branches which it knows are unreachable based on comptime expressions (builtin.is_test
).
2 Likes
Sze
February 5, 2025, 8:22pm
5
You also could use build options to pass in the value and make it changeable from the outside.
In build.zig:
const use_native_c_inner_loops = b.option(bool, "loops", "use native C inner loops") orelse false;
const options = b.addOptions();
options.addOption(bool, "use_native_c_inner_loops", use_native_c_inner_loops);
exe.addOptions("config", options);
Then in your code
const config = @import("config");
if (config.use_native_c_inner_loops) ...
This means that you can do zig build run -Dloops to enagle the feature.
Alternatively you could import a module @import("settings")
and then use the build system to switch between two different implementations for that module, or even generate one of them (build options are one way to generate a module of imported options).
This is related:
You can get most of this same behavior even without introducing new concepts.
Have your types be parametrized in a non-generic way with type parameters that come from other Zig modules, and then use the Zig build system to implement “generics” when composing an output executable (or a test one, or whatever artifact you’re generating).
In concrete (but a bit pseudozig) terms:
// Replica.zig
const StateMachine = @import("StateMachine");
const Storage = @import("Storage");
state: StateMachine,
…
At this point, the approach with test_overwrite_value in the structure seems most reasonable since it won’t be included in the production code anyway.
pub const Superblock = extern struct {
//use in tests
pub threadlocal var test_overwrite_block_size: ?u64 = null;
//fields
//...
//methods
pub fn getBlockSize(self: *const Superblock) u64 {
return if (!builtin.is_test) @as(u64, 1024) << @as(u6, @truncate(self.log_block_size)) else test_overwrite_block_size.?;
}
}