Testing
Zig has testing built into the language. Test blocks live alongside your code, run with zig test, and have access to the standard testing library for assertions and memory leak detection.
Test Blocks
Tests are declared with the test keyword and live anywhere in your source files.
const std = @import("std");
fn add(a: i32, b: i32) i32 {
return a + b;
}
fn factorial(n: u32) u64 {
if (n == 0) return 1;
var result: u64 = 1;
var i: u32 = 1;
while (i <= n) : (i += 1) {
result *= i;
}
return result;
}
pub fn main() !void {
// Demonstrate the functions
std.debug.print("add(2, 3) = {}\n", .{add(2, 3)});
std.debug.print("add(-1, 1) = {}\n", .{add(-1, 1)});
std.debug.print("factorial(5) = {}\n", .{factorial(5)});
std.debug.print("factorial(0) = {}\n", .{factorial(0)});
std.debug.print("factorial(10) = {}\n", .{factorial(10)});
// In a real project, these would be test blocks:
// test "add positive numbers" {
// try std.testing.expectEqual(@as(i32, 5), add(2, 3));
// }
// test "factorial of 5" {
// try std.testing.expectEqual(@as(u64, 120), factorial(5));
// }
// Manual assertions for playground
std.debug.print("\nAssertions:\n", .{});
std.debug.print("add(2,3) == 5: {}\n", .{add(2, 3) == 5});
std.debug.print("factorial(5) == 120: {}\n", .{factorial(5) == 120});
std.debug.print("factorial(0) == 1: {}\n", .{factorial(0) == 1});
}
std.testing Assertions
The standard library provides typed assertion functions.
const std = @import("std");
fn clamp(value: i32, min_val: i32, max_val: i32) i32 {
if (value < min_val) return min_val;
if (value > max_val) return max_val;
return value;
}
fn contains(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
var i: usize = 0;
while (i + needle.len <= haystack.len) : (i += 1) {
if (std.mem.eql(u8, haystack[i..][0..needle.len], needle)) return true;
}
return false;
}
pub fn main() !void {
// expectEqual — checks two values are equal
std.debug.print("clamp(5, 0, 10) = {}\n", .{clamp(5, 0, 10)});
std.debug.print("clamp(-5, 0, 10) = {}\n", .{clamp(-5, 0, 10)});
std.debug.print("clamp(15, 0, 10) = {}\n", .{clamp(15, 0, 10)});
// expect — checks a boolean condition
std.debug.print("\ncontains(\"hello world\", \"world\") = {}\n", .{contains("hello world", "world")});
std.debug.print("contains(\"hello\", \"xyz\") = {}\n", .{contains("hello", "xyz")});
// expectEqualStrings — for string comparison
const greeting = "Hello";
std.debug.print("\nString comparison:\n", .{});
std.debug.print("\"Hello\" == \"Hello\": {}\n", .{std.mem.eql(u8, greeting, "Hello")});
// expectEqualSlices — for slice comparison
const a = [_]i32{ 1, 2, 3 };
const b = [_]i32{ 1, 2, 3 };
std.debug.print("Slices equal: {}\n", .{std.mem.eql(i32, &a, &b)});
// In test blocks you would write:
// try std.testing.expectEqual(@as(i32, 5), clamp(5, 0, 10));
// try std.testing.expect(contains("hello world", "world"));
// try std.testing.expectEqualStrings("Hello", greeting);
// try std.testing.expectEqualSlices(i32, &a, &b);
}
Testing with Allocators
The testing allocator detects memory leaks and use-after-free.
const std = @import("std");
const Stack = struct {
items: std.ArrayList(i32),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator) Stack {
return .{ .items = .empty, .allocator = allocator };
}
fn deinit(self: *Stack) void {
self.items.deinit(self.allocator);
}
fn push(self: *Stack, value: i32) !void {
try self.items.append(self.allocator, value);
}
fn pop(self: *Stack) ?i32 {
if (self.items.items.len == 0) return null;
return self.items.pop();
}
fn peek(self: *const Stack) ?i32 {
if (self.items.items.len == 0) return null;
return self.items.items[self.items.items.len - 1];
}
fn size(self: *const Stack) usize {
return self.items.items.len;
}
};
pub fn main() !void {
// Using DebugAllocator (in tests you'd use std.testing.allocator)
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer {
const check = gpa.deinit();
if (check == .leak) {
std.debug.print("Memory leak detected!\n", .{});
} else {
std.debug.print("No memory leaks!\n", .{});
}
}
var stack = Stack.init(gpa.allocator());
defer stack.deinit();
// Test push and peek
try stack.push(10);
try stack.push(20);
try stack.push(30);
std.debug.print("Size after 3 pushes: {}\n", .{stack.size()});
std.debug.print("Peek: {}\n", .{stack.peek().?});
// Test pop
const val = stack.pop().?;
std.debug.print("Popped: {}\n", .{val});
std.debug.print("Size after pop: {}\n", .{stack.size()});
// Test pop until empty
_ = stack.pop();
_ = stack.pop();
std.debug.print("Pop from empty: {?}\n", .{stack.pop()});
std.debug.print("Peek empty: {?}\n", .{stack.peek()});
// In a real test file:
// test "stack operations" {
// var stack = Stack.init(std.testing.allocator);
// defer stack.deinit();
// try stack.push(42);
// try std.testing.expectEqual(@as(?i32, 42), stack.peek());
// try std.testing.expectEqual(@as(?i32, 42), stack.pop());
// try std.testing.expectEqual(@as(?i32, null), stack.pop());
// }
}
Error Testing
Test that functions return expected errors.
const std = @import("std");
const ParseError = error{
InvalidCharacter,
Overflow,
Empty,
};
fn parsePositiveInt(input: []const u8) ParseError!u32 {
if (input.len == 0) return ParseError.Empty;
var result: u64 = 0;
for (input) |c| {
if (c < '0' or c > '9') return ParseError.InvalidCharacter;
result = result * 10 + (c - '0');
if (result > std.math.maxInt(u32)) return ParseError.Overflow;
}
return @intCast(result);
}
pub fn main() !void {
// Test successful parsing
if (parsePositiveInt("123")) |val| {
std.debug.print("Parsed '123': {}\n", .{val});
} else |err| {
std.debug.print("Unexpected error: {}\n", .{err});
}
// Test error cases
if (parsePositiveInt("")) |val| {
std.debug.print("Unexpected value: {}\n", .{val});
} else |err| {
std.debug.print("Empty string error: {}\n", .{err});
}
if (parsePositiveInt("12a3")) |val| {
std.debug.print("Unexpected value: {}\n", .{val});
} else |err| {
std.debug.print("Invalid char error: {}\n", .{err});
}
if (parsePositiveInt("99999999999")) |val| {
std.debug.print("Unexpected value: {}\n", .{val});
} else |err| {
std.debug.print("Overflow error: {}\n", .{err});
}
// In test blocks, use expectError:
// test "parsePositiveInt errors" {
// try std.testing.expectError(ParseError.Empty, parsePositiveInt(""));
// try std.testing.expectError(ParseError.InvalidCharacter, parsePositiveInt("abc"));
// try std.testing.expectError(ParseError.Overflow, parsePositiveInt("99999999999"));
// }
std.debug.print("\nAll error tests passed!\n", .{});
}
Try It Yourself
const std = @import("std");
// A simple string buffer with tests
const StringBuffer = struct {
data: std.ArrayList(u8),
allocator: std.mem.Allocator,
fn init(allocator: std.mem.Allocator) StringBuffer {
return .{ .data = .empty, .allocator = allocator };
}
fn deinit(self: *StringBuffer) void {
self.data.deinit(self.allocator);
}
fn append(self: *StringBuffer, text: []const u8) !void {
try self.data.appendSlice(self.allocator, text);
}
fn length(self: *const StringBuffer) usize {
return self.data.items.len;
}
fn toString(self: *const StringBuffer) []const u8 {
return self.data.items;
}
fn clear(self: *StringBuffer) void {
self.data.clearRetainingCapacity();
}
fn contains(self: *const StringBuffer, needle: []const u8) bool {
return std.mem.indexOf(u8, self.data.items, needle) != null;
}
};
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer {
const check = gpa.deinit();
if (check == .leak) std.debug.print("LEAK!\n", .{});
}
var buf = StringBuffer.init(gpa.allocator());
defer buf.deinit();
// Test append
try buf.append("Hello");
try buf.append(", ");
try buf.append("World!");
std.debug.print("Buffer: \"{s}\"\n", .{buf.toString()});
std.debug.print("Length: {}\n", .{buf.length()});
// Test contains
std.debug.print("Contains 'World': {}\n", .{buf.contains("World")});
std.debug.print("Contains 'Zig': {}\n", .{buf.contains("Zig")});
// Test clear
buf.clear();
std.debug.print("After clear, length: {}\n", .{buf.length()});
std.debug.print("After clear, empty: {}\n", .{buf.length() == 0});
// Reuse after clear
try buf.append("Reused!");
std.debug.print("After reuse: \"{s}\"\n", .{buf.toString()});
}
Key Takeaways
- Test blocks (
test "name" { ... }) live alongside source code and run withzig test std.testing.expectEqual,expect,expectErrorare the core assertion functionsstd.testing.allocatordetects memory leaks — if you forgetdeinit, the test fails- Test error cases with
expectErrorto verify functions fail correctly - Tests are compiled and run separately — they don't affect production builds
- Use
deferin tests for cleanup, just like in regular code
Pro Tip: Always use
std.testing.allocatorin tests instead of a general-purpose allocator. It's specifically designed to catch memory leaks and will fail your test if any allocation isn't freed. This catches bugs that would be invisible with a regular allocator.
Next Steps
Now that you can write tests, it's time to learn how Zig's build system ties everything together -- compiling, linking, running tests, and even cross-compiling for other platforms, all from a single build.zig file.
Next lesson
Zig Build System — build.zig Tutorial
Learn the Zig build system — configure build.zig for compilation, testing, and dependencies without Make or CMake. Free hands-on tutorial with runnable examples.
25 min