Skip to editor content
learningzig.orglesson 12 of 25

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 with zig test
  • std.testing.expectEqual, expect, expectError are the core assertion functions
  • std.testing.allocator detects memory leaks — if you forget deinit, the test fails
  • Test error cases with expectError to verify functions fail correctly
  • Tests are compiled and run separately — they don't affect production builds
  • Use defer in tests for cleanup, just like in regular code

Pro Tip: Always use std.testing.allocator in 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