Skip to content

Testing Strategy

Tests should be close to the code they check. Zig makes this easy with test blocks.

Tests should be close to the code they check. Zig makes this easy with test blocks.

const std = @import("std");

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

test "add positive numbers" {
    try std.testing.expectEqual(@as(i32, 30), add(10, 20));
}

Run it:

zig test main.zig

A test block has a name and a body.

test "add positive numbers" {
    ...
}

The body is ordinary Zig code. It may declare variables, call functions, allocate memory, and return errors.

The expression:

try std.testing.expectEqual(@as(i32, 30), add(10, 20));

checks that two values are equal. If they are not equal, the test fails.

The first value is the expected value. The second value is the actual value.

Tests should cover behavior, not implementation. A useful test says what the function must do.

fn clamp(n: i32, low: i32, high: i32) i32 {
    if (n < low) return low;
    if (n > high) return high;
    return n;
}

test "clamp returns low when value is too small" {
    try std.testing.expectEqual(@as(i32, 0), clamp(-5, 0, 10));
}

test "clamp returns high when value is too large" {
    try std.testing.expectEqual(@as(i32, 10), clamp(15, 0, 10));
}

test "clamp returns value inside range" {
    try std.testing.expectEqual(@as(i32, 7), clamp(7, 0, 10));
}

Small tests are better than large tests. Each test should have one reason to fail.

A parser needs tests for valid input and invalid input.

const ParseError = error{
    Empty,
    InvalidDigit,
};

fn parseDigit(text: []const u8) ParseError!u8 {
    if (text.len == 0) return error.Empty;
    if (text.len != 1) return error.InvalidDigit;

    const c = text[0];

    if (c < '0' or c > '9') return error.InvalidDigit;

    return c - '0';
}

test "parseDigit parses one digit" {
    try std.testing.expectEqual(@as(u8, 7), try parseDigit("7"));
}

test "parseDigit rejects empty input" {
    try std.testing.expectError(error.Empty, parseDigit(""));
}

test "parseDigit rejects non-digit input" {
    try std.testing.expectError(error.InvalidDigit, parseDigit("x"));
}

expectError checks that an expression returns a specific error.

try std.testing.expectError(error.Empty, parseDigit(""));

This is better than checking only that some error occurred. The test states the exact contract.

For code that allocates memory, use the testing allocator.

fn duplicate(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
    const out = try allocator.alloc(u8, text.len);
    @memcpy(out, text);
    return out;
}

test "duplicate copies bytes" {
    const allocator = std.testing.allocator;

    const copy = try duplicate(allocator, "zig");
    defer allocator.free(copy);

    try std.testing.expectEqualStrings("zig", copy);
}

The testing allocator helps detect leaks in tests. If memory is allocated and not freed, the test runner can report it.

Tests should also check edge cases. Good edge cases usually come from boundaries:

Function kindEdge cases
parserempty input, malformed input, largest valid input
numeric functionzero, one, negative values, overflow boundary
collectionempty collection, one item, many items
I/O codemissing file, empty file, permission failure
allocator codeallocation failure, cleanup after partial work

For larger programs, keep pure logic separate from I/O. Pure functions are easier to test.

Instead of testing this directly:

fn run(path: []const u8) !void {
    const cwd = std.fs.cwd();

    var file = try cwd.openFile(path, .{});
    defer file.close();

    // parse and execute
}

split the work:

fn parseLine(line: []const u8) !i32 {
    return try std.fmt.parseInt(i32, line, 10);
}

Now parseLine can be tested without creating files.

test "parseLine parses decimal integer" {
    try std.testing.expectEqual(@as(i32, 123), try parseLine("123"));
}

test "parseLine rejects letters" {
    try std.testing.expectError(error.InvalidCharacter, parseLine("abc"));
}

Tests do not remove the need for simple code. They protect simple code from quiet changes.

A practical strategy is:

  1. Test public behavior first.
  2. Test error cases explicitly.
  3. Test allocation and cleanup.
  4. Keep I/O thin.
  5. Move decisions into small functions.

For a build-based project, add a test step in build.zig.

const std = @import("std");

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

    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);
}

Run it:

zig build test

This gives the project one command for tests, regardless of how many source files and artifacts it later contains.

Exercise 20-26. Write tests for the sub command from the calculator.

Exercise 20-27. Add tests for missing command-line arguments by moving parsing into a function.

Exercise 20-28. Write a test that uses std.testing.allocator.

Exercise 20-29. Add a test step to a build script.

Exercise 20-30. Write one test that proves cleanup happens after an error.