Zig Testing
Zig has first-class testing built directly into the language. Test blocks live in the same files as the code they test. The compiler compiles them only when you run tests — they add nothing to production binaries. Writing tests in Zig is simple, and the output is clear about what failed and why.
Writing Your First Test
const std = @import("std");
fn add(a: i32, b: i32) i32 {
return a + b;
}
test "add returns correct sum" {
try std.testing.expectEqual(@as(i32, 7), add(3, 4));
}
Run the test:
zig test math.zig
Output when passing:
All 1 tests passed.
Output when failing (if add returned 8 instead of 7):
FAIL (TestExpectedEqual): math.test.add returns correct sum expected 7, found 8
Test Block Anatomy
test "descriptive name" {
│ │
│ └─ What you are testing
│ ("add two numbers", "handles empty input", ...)
│
└─ keyword
try std.testing.expect(...);
// try: propagates test failure
// testing.*: assertion functions
}
Testing Assertions
Function | Checks ──────────────────────────────────────┼────────────────────────────── expect(condition) | condition is true expectEqual(expected, actual) | two values are equal expectEqualStrings(expected, actual) | two strings match expectEqualSlices(T, exp, act) | two slices have same content expectError(err, result) | result is a specific error expectApproxEqAbs(a, b, tolerance) | floats within tolerance expectApproxEqRel(a, b, tolerance) | relative float equality
test "various assertions" {
try std.testing.expect(5 > 3);
try std.testing.expectEqual(@as(u32, 100), 50 + 50);
try std.testing.expectEqualStrings("hello", "hello");
const arr1 = [_]u8{ 1, 2, 3 };
const arr2 = [_]u8{ 1, 2, 3 };
try std.testing.expectEqualSlices(u8, &arr1, &arr2);
}
Testing Error Returns
fn divide(a: f64, b: f64) !f64 {
if (b == 0) return error.DivisionByZero;
return a / b;
}
test "divide by zero returns error" {
try std.testing.expectError(error.DivisionByZero, divide(10, 0));
}
test "divide produces correct result" {
const result = try divide(10.0, 4.0);
try std.testing.expectApproxEqAbs(result, 2.5, 0.001);
}
Multiple Tests Per File
const std = @import("std");
fn clamp(val: i32, lo: i32, hi: i32) i32 {
if (val < lo) return lo;
if (val > hi) return hi;
return val;
}
test "clamp value in range returns same" {
try std.testing.expectEqual(@as(i32, 5), clamp(5, 0, 10));
}
test "clamp below min returns min" {
try std.testing.expectEqual(@as(i32, 0), clamp(-5, 0, 10));
}
test "clamp above max returns max" {
try std.testing.expectEqual(@as(i32, 10), clamp(99, 0, 10));
}
Test run result: ✓ clamp value in range returns same ✓ clamp below min returns min ✓ clamp above max returns max All 3 tests passed.
Tests with Memory Allocation
Use std.testing.allocator inside tests — it automatically checks for memory leaks after each test:
test "ArrayList grows correctly" {
const allocator = std.testing.allocator;
var list = std.ArrayList(u32).init(allocator);
defer list.deinit();
try list.append(1);
try list.append(2);
try list.append(3);
try std.testing.expectEqual(@as(usize, 3), list.items.len);
try std.testing.expectEqual(@as(u32, 2), list.items[1]);
}
std.testing.allocator: ┌─────────────────────────────────────────┐ │ Wraps GeneralPurposeAllocator │ │ After test ends: │ │ - checks all memory was freed │ │ - reports leak if deinit was skipped │ └─────────────────────────────────────────┘
Organizing Tests in a Project
Option 1: Tests in same file as code (recommended for small modules) src/ └── math.zig ← functions AND their tests in one file Option 2: Separate test file src/ ├── math.zig ← functions only └── math_test.zig ← tests that import math.zig
// math_test.zig
const math = @import("math.zig");
const std = @import("std");
test "math.add" {
try std.testing.expectEqual(@as(i32, 9), math.add(4, 5));
}
Running Tests in a Build Project
In a project using build.zig, add a test step:
// In build.zig:
const tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_tests.step);
zig build test
|
compile test binary
|
run all test blocks in main.zig (and imported files)
|
report pass/fail per test
Filtering Tests
zig test src/main.zig --test-filter "clamp" ↑ runs only tests whose name contains "clamp" zig test src/main.zig --test-filter "add" ↑ runs only tests whose name contains "add"
Test-Driven Example: Validating a Password
const std = @import("std");
const PasswordError = error{ TooShort, NoDigit, NoUpper };
fn validatePassword(pw: []const u8) PasswordError!void {
if (pw.len < 8) return error.TooShort;
var has_digit = false;
var has_upper = false;
for (pw) |c| {
if (c >= '0' and c <= '9') has_digit = true;
if (c >= 'A' and c <= 'Z') has_upper = true;
}
if (!has_digit) return error.NoDigit;
if (!has_upper) return error.NoUpper;
}
test "short password rejected" {
try std.testing.expectError(error.TooShort, validatePassword("Ab1"));
}
test "password without digit rejected" {
try std.testing.expectError(error.NoDigit, validatePassword("AbcdefGH"));
}
test "password without uppercase rejected" {
try std.testing.expectError(error.NoUpper, validatePassword("abcdef12"));
}
test "valid password accepted" {
try validatePassword("Secure123");
}
Output:
All 4 tests passed.
Writing tests alongside each function keeps them up to date and makes it easy to confirm that changes to a function do not break its expected behavior. Zig's simple test syntax removes every reason to skip writing tests.
