How to test code that interacts with I/O?

I’m trying to refactor my code to be more testable.

The code I am writing does a lot of interactions with the ethernet port.

Here is an example:

/// read a packed struct from SII, using station addressing
/// this function sends some stuff over the ethernet port and the 
/// resulting responses are parsed and returned as the packed struct type
/// provided.
pub fn readSIIFP_ps(
    port: *Port,
    comptime T: type,
    station_address: u16,
    eeprom_address: u16,
    retries: u32,
    recv_timeout_us: u32,
    eeprom_timeout_us: u32,
) !T {
    // count how many 4-byte transactions need to be performed
    const n_4_bytes = @divExact(@bitSizeOf(T), 32);
   // stack allocated buffer to assemble the results of each 4-byte transaction
    var bytes: [@divExact(@bitSizeOf(T), 8)]u8 = undefined;
   // do the transactions
    for (0..n_4_bytes) |i| {
        // send and recv some complex stuff over the ethernet port
        // resulting in an error or 4 bytes.
        const source: [4]u8 = try readSII4ByteFP(
            port,
            station_address,
            eeprom_address + 2 * @as(u16, @intCast(i)), // eeprom address is WORD address
            retries,
            recv_timeout_us,
            eeprom_timeout_us,
        );
        // assemble however many of these 4 byte sections into memory
        @memcpy(bytes[i * 4 .. i * 4 + 4], &source);
    }
    // interpret the memory correctly based on host endianness
    return nic.packFromECat(T, bytes);
}

I realize that I should be able to test this code without an etherenet port. Perhaps my function should accept a std.io.Reader so I can inject my own 4-byte sequences to test against? But then I need to figure out how to construct one of these famous readers…

1 Like

Reader is definitely one option - another is comptime function specialization.

const FuncType = fn (i32) void;

pub fn debug_function(value: i32) void {
    std.log.info("{}", .{ value });
}

pub fn actual_function(value: i32) void {
    // something...
}

// this is the function we will specialize
pub foo_impl(x: i32, comptime impl: FuncType) void {
    impl(x);
}

// here's the library entry point...
pub fn foo(x: i32) void {
    foo_impl(x, actual_function);
}

// for debugging...
foo_impl(42, debug_function);

It’s kind of a comptime “dependency injection” approach. The issue with a reader is that you may not want one all the time and now you have to think about that extra parameter. You could comptime swap out the type for void, but every place that uses this may need attention too.

2 Likes

To be clear, I’m suggesting that you may want to specialize the readSII4ByteFP function call for debugging.

My only qualms about this is that it may significantly decrease the readability of my code.

Perhaps this is a skill issue due to me being a 2 month old zigling, but I have read a few libraries with impl in them and I only now understand what they were trying to do.

1 Like

I’d say that really depends on if you overdo it. It’s easy to go off the deep end and specialize absolutely everything.

A good rule of thumb is to keep it shallow as you can - try not to make long chains of specialization that make it hard to find where the thing is used. Either way, it’s just an idea and probably a direct way to solve your issue. I’d give it a try and see what you come up with.

1 Like

one thought is that if you’re testing against four-byte sequences you write yourself, maybe the thing you’re trying to test is actually a function that accepts a four-byte sequence and performs some task or transformation. such a function doesn’t necessarily need a Reader as a parameter (although maybe it could be useful)

2 Likes

You can also use namespaces to add variant declarations to a container given a comptime-known value, like this:

/// This value has to be comptime-known
pub const use_q: bool = false;

pub const SomeStruct = struct {
    q: usize,
    b: u8,

    const ANameSpace = blk: {
        if (use_q) {
            break :blk struct {
                pub fn say(s: SomeStruct) void {
                    std.debug.print("{d}\n", .{s.q});
                }
            };
        } else {
            break :blk struct {
                pub fn say(s: SomeStruct) void {
                    std.debug.print("{d}\n", .{s.b});
                }
            };
        }
    };

    pub usingnamespace ANameSpace;
};

test "namespaced struct declaration" {
    const some_struct = SomeStruct{ .q = 42, .b = 9 };
    some_struct.say();
}

If use_q is true this prints “42”, otherwise it prints “9”. So you can use this to stub in testing versions of whatever you’d like, by having const builtin = @import("builtin"); and using builtin.is_test in conditionals.

There’s a proposal to remove usingnamespace, which would be a shame. There are other ways to do this, but they aren’t as good.

I meant to add: if you can make a testing version of a given function using a few tweaks to that function, the if (builtin.is_test) conditionals will work in the function itself, and impose no runtime cost on production code. Only one of the branches will get compiled at any given time.

all of these techniques feel really cool and flashy, but i will say my preference is for the test to execute, as much as possible, code that will also execute in the program itself. setting up the conditions to feed your code the input you want to test seems to me to be the job of the test block, and chunking your code so that functions contain the right amount of testable business logic seems to me to be your job as the code author.

I agree with that as far as it goes, but where that breaks down is when access to some resource is an integral part of how code functions: a port in this case. The parsing logic lives in its own function, so it can just be tested on bytes, but the reading logic gives a couple of choices: find a way to mock the port, or test the code by setting up hardware in a special way and sending Ethernet frames. It makes sense to to both, but one of these requires special circumstances which won’t always obtain.

Zig offers a lot of ways to do this thanks to comptime: for instance, the type constant Port could be assigned to different structs depending on the value of builtin.is_test. But that’s likely to involve a lot of code duplication, relative to just using a few conditionals to make a port into a testing port when it needs to be.

1 Like

So I tried the reader approach. And I don’t think its going to work.

My io operations are very expensive and error prone. So I would prefer to minimize them. Which means I need both a Reader and a SeekableStream to effectively use it.

Which I guess means I need to pass a reader and SeekableStream interfaces into my functions. Which is a bit annoying.

And all this to only obtain more testable code (code I can test using fixedBufferStream).

I think @AndrewCodeDev may have been right on the first suggestion of the comptime functional specialization.

pub const SIIStream = struct {
    port: *Port,
    station_address: u16,
    retries: u32,
    recv_timeout_us: u32,
    eeprom_timeout_us: u32,
    eeprom_address: u16, // WORD (2-byte) address

    last_four_bytes: [4]u8 = .{ 0, 0, 0, 0 },
    remainder: u2 = 0,

    pub fn init(
        port: *Port,
        station_address: u16,
        eeprom_address: u16,
        retries: u32,
        recv_timeout_us: u32,
        eeprom_timeout_us: u32,
    ) SIIStream {
        return SIIStream{
            .port = port,
            .station_address = station_address,
            .eeprom_address = eeprom_address,
            .retries = retries,
            .recv_timeout_us = recv_timeout_us,
            .eeprom_timeout_us = eeprom_timeout_us,
        };
    }

    pub const ReadError = error{
        Timeout,
        SocketError,
    };

    pub fn reader(self: *SIIStream) std.io.AnyReader {
        return .{ .context = self, .readFn = read };
    }

    fn read(self: *SIIStream, buf: []u8) ReadError!usize {
        if (self.remainder == 0) {
            self.last_four_bytes = try readSII4ByteFP(
                self.port,
                self.station_address,
                self.eeprom_address, // eeprom address is WORD address
                self.retries,
                self.recv_timeout_us,
                self.eeprom_timeout_us,
            );
            self.eeprom_address += 2;
        }

        if (buf.len >= 4) {
            @memcpy(buf[0..4], self.last_four_bytes[0..]);
            self.remainder = 0;

            return 4;
        } else {
            @memcpy(buf, self.last_four_bytes[0..buf.len]);
            self.remainder -%= @as(u2, @intCast(buf.len));
            return buf.len;
        }
    }

    pub fn seekByWord(self: *SIIStream, amt: i16) void {
        self.eeprom_address +%= amt;
        self.remainder = 0; // next call to read will always read eeprom
    }
};
2 Likes

I’m reading an EEPROM (basically slow memory) and I need to parse the information correctly. Where I read next is based on information I have gotten from previous reads.

It has a memory layout that kind of looks like this:

region_1_header
region_1_data
region_2_header
region_2_data
...

So I to get to region_2_data I need to read region_1_header (which contains the length of region_1_data and skip past region_1_data to reach `region_2_data’ etc.

This is the logic I am trying to test which is I think has an interface like a reader.

Hey @kj4tmp,

You can take a look at how I did my mocking in Zig here:

It’s pretty much the same strategy that I do in any language but adjusted for Zig (interfaces, real and mock implementation implementing those functions). I think you are on the right track with returning a AnyReader from the reader function.

But yea, with any code we write that is written to be testable code, there is definitely a little bit of an adjustment and forethought required.

1 Like