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.

Leave a Comment

Your email address will not be published. Required fields are marked *