Test parameterization

For the purpose of having a separate test per parameter/function combination, you can write something like:

const std = @import("std");

fn makeTestCase(comptime i: isize) type {
    return struct {
        test "greater than 0" {
            try std.testing.expect(i > 0);
        }
        test "less than 10" {
            try std.testing.expect(i < 10);
        }
    };
}

const testParams = [_]isize{ 1, 2, 3 };

test {
    inline for (testParams) |i| std.testing.refAllDecls(makeTestCase(i));
}

This will run 7 tests (1 for the top-level anonymous one, and 2 for each parameter).

The main downside of this approach is that when a test fails, the test runner will print the name of the failed test, but not the value of the parameter (i).

If test would accept a comptime string we could encode the parameter in the test name using e.g. comptimePrint("i={d} greater than 0") but unfortunately it looks like test only accepts a string literal or an identifier.

A workaround is to manually print the parameters on failure, like this:

fn makeTestCase(comptime i: isize) type {
    return struct {
        fn printParameters() void {
          std.debug.print("i={d}\n", .{i});
        }
        test "greater than 0" {
            errdefer printParameters();
            try std.testing.expect(i > 0);
        }
        test "less than 10" {
            errdefer printParameters();
            try std.testing.expect(i < 10);
        }
    };
}

It has the downside that you need to add the errdefer to each test function separately, but I don’t think it’s too cumbersome. (Maybe some clever meta-programming is possible to eliminate this, but I’m not sure.)

Then a failure looks like this:

Test [4/7] test.greater than 0... i=0
Test [4/7] test.greater than 0... FAIL (TestUnexpectedResult)
1 Like

I agree with parameterisation of testcases being useful. I wrote a framework for doing this in Maths tests resurrected (exp and log functions) with bugfixes by LewisGaul · Pull Request #16808 · ziglang/zig · GitHub but Andrew wasn’t keen on the complexity - the feedback was to just do multiple asserts in each testcase. At some point I intend to strip out the complexity from that PR as requested, but I would very much like some built-in test parameterisation support.

Hi everyone (this is actually my first post here)!

Since no one has done it yet, I just want to point out that it’s not very complicated to generate those tests the old-school way: generating a text file.

This is my example main.zig (without the actual main function because we only care about tests). It includes the hello-world test from zig init-exe as well as a few test cases that I created.

const std = @import("std");

pub const tests = .{
    struct {
        pub const name = "simple_test";
        pub fn run() !void {
            var list = std.ArrayList(i32).init(std.testing.allocator);
            defer list.deinit(); // try commenting this out and see if zig detects the memory leak!
            try list.append(42);
            try std.testing.expectEqual(@as(i32, 42), list.pop());
        }
    },
    ParamTest_isSmallNumber("isSmallNumber", 1, true),
    ParamTest_isSmallNumber("isSmallNumber", 10, true),
    ParamTest_isSmallNumber("isSmallNumber", 100, false),
};

fn isSmallNumber(number: u32) bool {
    return number < 101;
}

fn ParamTest_isSmallNumber(comptime base_name: []const u8, comptime input: u32, comptime expected_result: bool) type {
    comptime var buffer: [base_name.len + 10]u8 = undefined;
    comptime var name_with_input = try std.fmt.bufPrint(&buffer, "{s} ({d})", .{base_name, input});

    return struct {
        pub const name = name_with_input;
        pub fn run() !void {
            const result = isSmallNumber(input);
            try std.testing.expectEqual(expected_result, result);
        }
    };
}

This is my generate_tests.zig which does what the name says.

const std = @import("std");

const source_tuples = .{
    .{ "main", @import("main.zig") },
};

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    var text = std.ArrayList(u8).init(arena.allocator());

    inline for (source_tuples) |tuple| {
        inline for (tuple[1].tests, 0..) |test_struct, index| {
            try text.writer().print("test \"{s}\" {{\n    try @import(\"{s}.zig\").tests[{d}].run();\n}}\n",
                .{ test_struct.name, tuple[0], index });
        }
    }

    try std.fs.cwd().writeFile("src/generated_tests.zig", text.items);
}

In my build.zig, I set it up so that tests are generated before being executed.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const generate_tests = b.addExecutable(.{
        .name = "generate_tests",
        .root_source_file = .{ .path = "src/generate_tests.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);
    const generate_tests_cmd = b.addRunArtifact(generate_tests);
    generate_tests_cmd.step.dependOn(b.getInstallStep());
    const generate_tests_step = b.step("generate_tests", "Generate the tests");
    generate_tests_step.dependOn(&generate_tests_cmd.step);

    const unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/generated_tests.zig" },
        .target = target,
        .optimize = optimize,
    });
    const run_unit_tests = b.addRunArtifact(unit_tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(generate_tests_step);
    test_step.dependOn(&run_unit_tests.step);
}

When I now just type zig build test, the following generated_tests.zig file is created before being used for testing.

test "simple_test" {
    try @import("main.zig").tests[0].run();
}
test "isSmallNumber (1)" {
    try @import("main.zig").tests[1].run();
}
test "isSmallNumber (10)" {
    try @import("main.zig").tests[2].run();
}
test "isSmallNumber (100)" {
    try @import("main.zig").tests[3].run();
}

And the output of running the tests looks like this (one of the test fails, of course).

run test: error: 'test.isSmallNumber (100)' failed: expected false, found true
<some_path>/testing.zig:84:17: 0x224fe2 in expectEqual__anon_2816 (test)
                return error.TestExpectedEqual;
                ^
<some_path>/src/main.zig:34:13: 0x2251a0 in run (test)
            try std.testing.expectEqual(expected_result, result);
            ^
<some_path>/src/generated_tests.zig:11:5: 0x2251e3 in test.isSmallNumber (100) (test)
    try @import("main.zig").tests[3].run();
    ^
run test: error: while executing test 'test.isSmallNumber (100)', the following test command failed:
<some_path>/test --listen=- 
Build Summary: 8/10 steps succeeded; 1 failed; 3/4 tests passed; 1 failed (disable with --summary none)
test transitive failure
└─ run test 3/4 passed, 1 failed
error: the following build command failed with exit code 1:
<some_path>/build <some_path>/zig <some_path> <some_path>/zig-cache <some_path>/zig test

I just typed this down in a few minutes so there is probably a lot of stuff that can be improved or scenarios where it doesn’t work. It does seem rather simple and effective to me though.

Update: I have to admit that it doesn’t simple very simple to create that tests tuple dynamically from an enum (or list of values in any form) like the OP wants to.

1 Like

Okay, I looked a bit further into this to actually solve the OP’s problem with my text generation approach and I think I did!

This is the main.zig:

const std = @import("std");
const KeyEvent = @import("KeyEvent.zig");
const parseEvent = KeyEvent.parse;

pub const tests = .{
    struct {
        pub const base_name = "parseEvent";

        pub const cases = .{
            .{ 0, KeyEvent{ .code = .{ .Char = ' ' } } },
            .{ 1, KeyEvent{ .code = .{ .Char = 'a' } } },
            .{ 31, KeyEvent{ .code = .{ .Char = '?' } } },
        };

        pub fn run(comptime case_index: usize) !void {
            const test_case = cases[case_index];
            const int = test_case[0];
            const expected_result = test_case[1];

            const result = try parseEvent(&.{ int }, false);
            try std.testing.expectEqual(expected_result, result.?);
        }

        pub fn caseName(comptime case_index: usize, buffer: []u8) ![]u8 {
            return try std.fmt.bufPrint(buffer, "{s}({d})", .{ base_name, cases[case_index][0] });
        }
    },
};

And this is the generate_tests.zig:

const std = @import("std");

const source_tuples = .{
    .{ "main", @import("main.zig") },
};

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();

    var text = std.ArrayList(u8).init(arena.allocator());
    defer text.deinit();

    var name_buffer: [999]u8 = undefined;

    inline for (source_tuples) |tuple| {
        const namespace = tuple[0];
        const source = tuple[1];
        try text.writer().print("const {s} = @import(\"{s}.zig\");\n", .{ namespace, namespace });
        inline for (source.tests, 0..) |tst, test_index| {
            inline for (0..tst.cases.len) |case_index| {
                const case_name = try tst.caseName(case_index, &name_buffer);
                try text.writer().print("test \"{s}\" {{\n", .{ case_name });
                try text.writer().print("    try {s}.tests[{d}].run({d});\n", .{ namespace, test_index, case_index });
                try text.writer().print("}}\n", .{});
            }
        }
    }

    try std.fs.cwd().writeFile("src/tests.zig", text.items);
}

Just FYI, the generated tests.zig looks like this:

const main = @import("main.zig");
test "parseEvent(0)" {
    try main.tests[0].run(0);
}
test "parseEvent(1)" {
    try main.tests[0].run(1);
}
test "parseEvent(31)" {
    try main.tests[0].run(2);
}

It should now be easy to add more tests and cases without touching generate_tests.zig again. Cases could also be proper structs, of course. Only cases itself needs to be a tuple.

2 Likes

Yup - for a generated approach, I’d go with something to this effect. I actually think file generation is under appreciated but I’ve been binging on it lately. It depends on if they want to take that approach or not.

1 Like

Wow. Lots of great replies and ideas. Thanks for all the feedback everyone!

2 Likes

It is very interesting, although it is not very user friendly, in case you need to use a lot of table driven tests.

I’m also curious about why use

pub const tests = .{
    struct {

instead of

pub const tests = struct {

If I understand the question correctly as “Why is tests itself a tuple?”, the answer is: I want to have the option to add other tests with their own sets of cases, theoretically. My example didn’t include any though so here they come:

pub const tests = .{
    struct {
        const base_name = "isSmallNumber";
        pub const cases = .{
            .{ .input = 1, .expected_result = true },
            .{ .input = 10, .expected_result = true },
            .{ .input = 100, .expected_result = false },
        };

        pub fn run(comptime case_index: usize) !void {
            const test_case = cases[case_index];
            const result = isSmallNumber(test_case.input);
            try std.testing.expectEqual(test_case.expected_result, result);
        }

        pub fn caseName(comptime case_index: usize, buffer: []u8) ![]u8 {
            const test_case = cases[case_index];
            return try std.fmt.bufPrint(buffer, "{s}({d})", .{ base_name, test_case.input });
        }
    },
    struct {
        const base_name = "parseEvent";
        pub const cases = .{
            .{ .input = 0, .expected_result = KeyEvent{ .code = .{ .Char = ' ' } } },
            .{ .input = 1, .expected_result = KeyEvent{ .code = .{ .Char = 'a' } } },
            .{ .input = 31, .expected_result = KeyEvent{ .code = .{ .Char = '?' } } },
        };

        pub fn run(comptime case_index: usize) !void {
            const test_case = cases[case_index];
            const result = try parseEvent(&.{ test_case.input }, false);
            try std.testing.expectEqual(test_case.expected_result, result.?);
        }

        pub fn caseName(comptime case_index: usize, buffer: []u8) ![]u8 {
            const test_case = cases[case_index];
            return try std.fmt.bufPrint(buffer, "{s}({d})", .{ base_name, test_case.input });
        }
    },
};

Thanks for the clarification.

However I feels that this add unnecessary complexity. Table driven testing reason is to make tests simpler, as an example sub tests.

Maybe a better approach is to update the test runner to support sub tests.

You might be right there. I really did not spend a lot of time designing this though. I just wanted to demonstrate with an example that the OP’s problem can be solved with, in my opinion, a reasonable amount of code and complexity. It can surely still be improved a lot and there are probably completely different approaches as well.