Best approach for mocking struct methods with different behavior in tests

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:

  1. 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
  1. 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
  1. 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 :wink:

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

You also could use build options to pass in the value and make it changeable from the outside.

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:

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.?;
    }
}