A table-driven test checks many input cases with one test loop.
Instead of writing many separate tests like this:
test "double handles one" {
try std.testing.expectEqual(@as(i32, 2), double(1));
}
test "double handles two" {
try std.testing.expectEqual(@as(i32, 4), double(2));
}
test "double handles ten" {
try std.testing.expectEqual(@as(i32, 20), double(10));
}You put the cases in a small table:
const std = @import("std");
fn double(n: i32) i32 {
return n * 2;
}
test "double multiplies numbers by two" {
const cases = [_]struct {
input: i32,
expected: i32,
}{
.{ .input = 1, .expected = 2 },
.{ .input = 2, .expected = 4 },
.{ .input = 10, .expected = 20 },
};
for (cases) |case| {
try std.testing.expectEqual(case.expected, double(case.input));
}
}This is called table-driven testing because the test data is written as a table of cases.
Why Use Table-Driven Tests
Table-driven tests are useful when the same logic should be checked with many inputs.
They work well for:
parsers
math functions
string functions
validation functions
small algorithms
boundary checks
error cases
They reduce repeated test code. The function call and expectation stay in one place. The inputs and expected outputs are easy to scan.
A Test Case Is Just Data
In Zig, a table of test cases is usually an array of anonymous structs.
Look at this part:
const cases = [_]struct {
input: i32,
expected: i32,
}{
.{ .input = 1, .expected = 2 },
.{ .input = 2, .expected = 4 },
.{ .input = 10, .expected = 20 },
};Read it as:
“Create an array. Each item has an input field and an expected field.”
The [_] means Zig should infer the array length.
The type of each item is:
struct {
input: i32,
expected: i32,
}Each row is one case:
.{ .input = 1, .expected = 2 }This style is explicit. A reader can understand the whole test by reading the cases.
Looping Through Cases
After defining the cases, use a for loop:
for (cases) |case| {
try std.testing.expectEqual(case.expected, double(case.input));
}For each case:
case.input is passed to the function.
case.expected is compared with the result.
The test fails if any row fails.
Start with a Normal Case
Suppose we have this function:
fn clamp(value: i32, min: i32, max: i32) i32 {
if (value < min) return min;
if (value > max) return max;
return value;
}A table-driven test can cover several cases:
const std = @import("std");
fn clamp(value: i32, min: i32, max: i32) i32 {
if (value < min) return min;
if (value > max) return max;
return value;
}
test "clamp limits a value to a range" {
const cases = [_]struct {
value: i32,
min: i32,
max: i32,
expected: i32,
}{
.{ .value = 5, .min = 0, .max = 10, .expected = 5 },
.{ .value = -3, .min = 0, .max = 10, .expected = 0 },
.{ .value = 99, .min = 0, .max = 10, .expected = 10 },
.{ .value = 0, .min = 0, .max = 10, .expected = 0 },
.{ .value = 10, .min = 0, .max = 10, .expected = 10 },
};
for (cases) |case| {
try std.testing.expectEqual(
case.expected,
clamp(case.value, case.min, case.max),
);
}
}This one test checks:
a value inside the range
a value below the range
a value above the range
the lower boundary
the upper boundary
That is a good fit for table-driven testing.
Add a Name Field for Better Debugging
A table-driven test has one weakness: when a row fails, it may not be obvious which case caused the failure.
You can add a name field:
test "clamp limits a value to a range" {
const cases = [_]struct {
name: []const u8,
value: i32,
min: i32,
max: i32,
expected: i32,
}{
.{
.name = "inside range",
.value = 5,
.min = 0,
.max = 10,
.expected = 5,
},
.{
.name = "below range",
.value = -3,
.min = 0,
.max = 10,
.expected = 0,
},
.{
.name = "above range",
.value = 99,
.min = 0,
.max = 10,
.expected = 10,
},
};
for (cases) |case| {
std.debug.print("case: {s}\n", .{case.name});
try std.testing.expectEqual(
case.expected,
clamp(case.value, case.min, case.max),
);
}
}This prints the case name before each check.
For small tests, this is not always necessary. For larger tables, a name field can save time.
Table-Driven Tests for Strings
Table-driven tests are also useful for string functions.
Example:
const std = @import("std");
fn isEmpty(text: []const u8) bool {
return text.len == 0;
}
test "isEmpty checks whether a string has no bytes" {
const cases = [_]struct {
input: []const u8,
expected: bool,
}{
.{ .input = "", .expected = true },
.{ .input = "a", .expected = false },
.{ .input = "hello", .expected = false },
};
for (cases) |case| {
try std.testing.expectEqual(case.expected, isEmpty(case.input));
}
}Here, the input is a string slice:
[]const u8That means “a read-only slice of bytes.”
Table-Driven Tests for Errors
You can also test error behavior.
Suppose we have a function that parses a single digit:
const ParseError = error{
InvalidDigit,
};
fn parseDigit(c: u8) !u8 {
if (c < '0' or c > '9') {
return ParseError.InvalidDigit;
}
return c - '0';
}We can test valid input with a table:
test "parseDigit parses valid digits" {
const cases = [_]struct {
input: u8,
expected: u8,
}{
.{ .input = '0', .expected = 0 },
.{ .input = '1', .expected = 1 },
.{ .input = '9', .expected = 9 },
};
for (cases) |case| {
try std.testing.expectEqual(case.expected, try parseDigit(case.input));
}
}Then test invalid input separately:
test "parseDigit rejects invalid characters" {
const cases = [_]u8{
'x',
'/',
':',
' ',
};
for (cases) |input| {
try std.testing.expectError(
ParseError.InvalidDigit,
parseDigit(input),
);
}
}This is clearer than mixing successful results and expected errors in one table.
Avoid Overcomplicated Tables
A table-driven test should make the test easier to read.
This is good:
const cases = [_]struct {
input: i32,
expected: i32,
}{
.{ .input = 1, .expected = 2 },
.{ .input = 2, .expected = 4 },
};This is usually too much for a beginner test:
const cases = [_]struct {
name: []const u8,
input: []const u8,
allocator: std.mem.Allocator,
mode: Mode,
flags: Flags,
expected_error: ?ParseError,
expected_output: ?Result,
cleanup: ?fn () void,
}{
// hard to read
};When a table has too many fields, split the test into smaller tests.
The goal is clarity, not cleverness.
Use Tables When the Shape Repeats
A table-driven test is a good choice when each case has the same shape.
For example:
input -> expected outputor:
input -> expected erroror:
value, min, max -> expected valueIt is less useful when every case needs different setup, different cleanup, or different assertions.
In that situation, separate test blocks are easier to read.
Keep Test Logic Simple
The loop should usually contain only the function call and the expectation.
Good:
for (cases) |case| {
try std.testing.expectEqual(case.expected, double(case.input));
}Less good:
for (cases) |case| {
// many branches
// several modes
// special cleanup
// multiple unrelated checks
}When the loop becomes complicated, the table is no longer helping.
A Complete Example
Here is a full example you can save as main.zig:
const std = @import("std");
fn startsWith(text: []const u8, prefix: []const u8) bool {
if (prefix.len > text.len) return false;
for (prefix, 0..) |ch, i| {
if (text[i] != ch) return false;
}
return true;
}
test "startsWith checks whether text begins with a prefix" {
const cases = [_]struct {
text: []const u8,
prefix: []const u8,
expected: bool,
}{
.{ .text = "zig", .prefix = "z", .expected = true },
.{ .text = "zig", .prefix = "zi", .expected = true },
.{ .text = "zig", .prefix = "zig", .expected = true },
.{ .text = "zig", .prefix = "zag", .expected = false },
.{ .text = "zig", .prefix = "ziglang", .expected = false },
.{ .text = "zig", .prefix = "", .expected = true },
.{ .text = "", .prefix = "", .expected = true },
.{ .text = "", .prefix = "z", .expected = false },
};
for (cases) |case| {
try std.testing.expectEqual(
case.expected,
startsWith(case.text, case.prefix),
);
}
}Run it:
zig test main.zigThis one test checks normal cases, boundary cases, and empty strings.
Table-driven tests are not special magic. They are just data plus a loop. The value comes from making repeated checks compact, readable, and easy to extend.