Zig Testing Guide: Built-in Test Runner, std.testing, and Comptime Tests

Zig Testing Guide: Built-in Test Runner, std.testing, and Comptime Tests

Zig has a built-in test runner — no external framework needed. Write test "name" { ... } blocks anywhere in your code, run zig test src/main.zig, and the compiler compiles and runs them. The std.testing module provides assertions, and comptime blocks can be tested too. This guide covers the full Zig testing workflow.

Key Takeaways

Tests live in source files, not separate test files. test "description" { ... } blocks can appear anywhere in a .zig file. zig test compiles only the test binary, not your main executable.

std.testing has a small but sufficient assertion API. expectEqual, expectError, expectStringEqual, expect — these cover most cases. Failures print a diff and return an error.

zig test runs tests from a single entry file and follows imports. Running zig test src/root.zig also runs tests in files imported by root.zig. You don't need to list every file.

Comptime tests verify compile-time logic. If your code uses comptime for generic programming or static validation, you can test that logic with comptime {} blocks in tests.

Allocator testing catches memory leaks. Use std.testing.allocator in tests — it's a leak-detecting allocator that fails the test if allocations aren't freed.

Zig's Built-in Test System

Zig treats testing as a first-class language feature. Unlike most languages that require a testing framework, Zig's compiler handles test discovery, compilation, and execution.

The test workflow:

# Run all tests in a file (and all files it imports)
zig <span class="hljs-built_in">test src/root.zig

<span class="hljs-comment"># Run tests with a specific build target
zig <span class="hljs-built_in">test src/root.zig -target x86_64-linux

<span class="hljs-comment"># Run tests from build.zig (recommended for projects)
zig build <span class="hljs-built_in">test

Writing Your First Test

Tests are test blocks in any .zig file:

// src/math.zig
const std = @import("std");

pub fn add(a: i32, b: i32) i32 {
    return a + b;
}

pub fn factorial(n: u64) u64 {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

test "add positive numbers" {
    try std.testing.expectEqual(5, add(2, 3));
}

test "add negative numbers" {
    try std.testing.expectEqual(-1, add(-3, 2));
}

test "factorial of 0 is 1" {
    try std.testing.expectEqual(@as(u64, 1), factorial(0));
}

test "factorial of 5 is 120" {
    try std.testing.expectEqual(@as(u64, 120), factorial(5));
}

Run with:

zig test src/math.zig

Output:

All 4 tests passed.

std.testing Assertion API

const testing = std.testing;

// Equality
try testing.expectEqual(expected, actual);

// Approximate equality (floats)
try testing.expectApproxEqAbs(expected, actual, tolerance);
try testing.expectApproxEqRel(expected, actual, max_relative);

// Boolean
try testing.expect(condition);           // Fails if false

// Strings and slices
try testing.expectEqualStrings(expected, actual);
try testing.expectEqualSlices(T, expected, actual);

// Error testing
try testing.expectError(expected_error, actual_result);

// Error sets
try testing.expectEqualErrors(expected, actual);

Testing Error Handling

Zig's error union type makes error testing explicit:

// src/parser.zig
const ParseError = error{
    InvalidFormat,
    EmptyInput,
    Overflow,
};

pub fn parsePositiveInt(s: []const u8) ParseError!u64 {
    if (s.len == 0) return ParseError.EmptyInput;
    var result: u64 = 0;
    for (s) |c| {
        if (c < '0' or c > '9') return ParseError.InvalidFormat;
        result = result * 10 + (c - '0');
    }
    return result;
}

test "parsePositiveInt valid input" {
    const result = try parsePositiveInt("42");
    try std.testing.expectEqual(@as(u64, 42), result);
}

test "parsePositiveInt empty input returns error" {
    const result = parsePositiveInt("");
    try std.testing.expectError(error.EmptyInput, result);
}

test "parsePositiveInt invalid char returns error" {
    const result = parsePositiveInt("12x");
    try std.testing.expectError(error.InvalidFormat, result);
}

Testing with the Leak-Detecting Allocator

std.testing.allocator wraps std.heap.GeneralPurposeAllocator with leak detection. Use it for any code that allocates memory:

// src/list.zig
const std = @import("std");
const Allocator = std.mem.Allocator;

pub fn ArrayList(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,
        allocator: Allocator,

        const Self = @This();

        pub fn init(allocator: Allocator) Self {
            return .{ .items = &[_]T{}, .len = 0, .allocator = allocator };
        }

        pub fn deinit(self: *Self) void {
            self.allocator.free(self.items);
        }

        pub fn append(self: *Self, item: T) !void {
            self.items = try self.allocator.realloc(self.items, self.len + 1);
            self.items[self.len] = item;
            self.len += 1;
        }
    };
}

test "ArrayList appends without leaking" {
    var list = ArrayList(i32).init(std.testing.allocator);
    defer list.deinit();

    try list.append(1);
    try list.append(2);
    try list.append(3);

    try std.testing.expectEqual(@as(usize, 3), list.len);
    try std.testing.expectEqual(@as(i32, 1), list.items[0]);
    try std.testing.expectEqual(@as(i32, 3), list.items[2]);
    // If deinit() is not called (defer fails), the test fails with a leak report
}

Comptime Testing

Test compile-time logic with comptime {} blocks inside tests:

// src/types.zig
pub fn isUnsigned(comptime T: type) bool {
    return switch (T) {
        u8, u16, u32, u64, usize => true,
        i8, i16, i32, i64, isize => false,
        else => @compileError("Not an integer type"),
    };
}

test "isUnsigned at comptime" {
    // These assertions run at compile time
    comptime {
        std.debug.assert(isUnsigned(u32) == true);
        std.debug.assert(isUnsigned(i32) == false);
        std.debug.assert(isUnsigned(u64) == true);
    }
}

test "isUnsigned at runtime" {
    // These run at test runtime
    try std.testing.expect(isUnsigned(u8));
    try std.testing.expect(!isUnsigned(i8));
}

Organizing Tests in a Project

For multi-file projects, use build.zig to configure tests:

// build.zig
const std = @import("std");

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

    // Main executable
    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(exe);

    // Test step
    const unit_tests = b.addTest(.{
        .root_source_file = .{ .path = "src/root.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(&run_unit_tests.step);
}

Then run:

zig build test

CI Integration

# .github/workflows/test.yml
name: Zig Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Zig
        uses: goto-bus-stop/setup-zig@v2
        with:
          version: 0.13.0

      - name: Run tests
        run: zig build test

Testing Across Targets

Zig compiles for many targets. Test your logic for cross-platform correctness:

# Test for WASM target
zig <span class="hljs-built_in">test src/root.zig -target wasm32-freestanding

<span class="hljs-comment"># Test for ARM
zig <span class="hljs-built_in">test src/root.zig -target aarch64-linux

<span class="hljs-comment"># Test with ReleaseSmall optimization
zig <span class="hljs-built_in">test src/root.zig -O ReleaseSmall

Common Patterns and Pitfalls

Forgetting try: Assertions return errors, so all testing.* calls need try. Missing try causes a compile error.

Type mismatches in expectEqual: Zig is strict about types. expectEqual(1, result) may fail if 1 is an integer literal and result is u64. Use @as(u64, 1) to specify the exact type.

Not using the test allocator: Using std.heap.page_allocator in tests doesn't detect leaks. Always use std.testing.allocator in tests that allocate memory.

Tests in separate files aren't discovered automatically: If you put tests in tests/test_foo.zig but don't import that file from your root, zig test src/root.zig won't find them. Import test files explicitly or use build.zig to add them.

Summary

Zig's built-in test system is one of the cleanest in systems languages:

  • Tests are test blocks in source files — no framework needed
  • std.testing.allocator catches memory leaks
  • comptime {} in tests verifies compile-time logic
  • zig build test via build.zig handles multi-file projects
  • Cross-target testing is built in

Start by adding test blocks to your existing source files. The zero-setup nature of Zig testing means there's no friction to getting started.

Read more