Skip to content

Tests

Zig has tests in the language.

Zig has tests in the language.

A test is written with the test keyword:

const std = @import("std");

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

test "add two numbers" {
    try std.testing.expect(add(2, 3) == 5);
}

Run it directly:

zig test src/main.zig

In a project, tests are usually run through zig build.

A test step looks like this:

const tests = b.addTest(.{
    .root_module = b.createModule(.{
        .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);

Now run:

zig build test

The build file compiles the tests, then runs the test executable.

A test block may appear beside the code it checks:

fn isDigit(c: u8) bool {
    return c >= '0' and c <= '9';
}

test "isDigit" {
    try std.testing.expect(isDigit('0'));
    try std.testing.expect(isDigit('9'));
    try std.testing.expect(!isDigit('a'));
}

This is the common style in Zig. Small tests stay near the function. Larger tests can go in separate files.

The std.testing namespace provides helpers:

try std.testing.expect(value);
try std.testing.expectEqual(expected, actual);
try std.testing.expectError(error.NotFound, result);

Use expect for boolean conditions.

try std.testing.expect(x > 0);

Use expectEqual when comparing values.

try std.testing.expectEqual(@as(u32, 42), value);

The explicit type is often useful. It prevents confusion between integer types.

Tests can allocate memory. Use the testing allocator:

test "array list" {
    var list = std.ArrayList(u8).init(std.testing.allocator);
    defer list.deinit();

    try list.append('a');
    try list.append('b');

    try std.testing.expectEqual(@as(usize, 2), list.items.len);
}

The testing allocator checks for leaks. If the test forgets to release memory, the test fails.

This is deliberate. In Zig, allocation is explicit, so tests should check that cleanup is also explicit.

A test can return an error. That is why calls often use try.

test "fallible operation" {
    try mayFail();
}

If mayFail returns an error, the test fails.

Files can contain many tests:

test "empty input" {
    // ...
}

test "one item" {
    // ...
}

test "many items" {
    // ...
}

Test names should describe the case being checked. They are not function names. They are messages for the reader.

A library often has one test step for the root file:

const lib_tests = b.addTest(.{
    .root_module = lib.root_module,
});

An executable may have tests too, but serious projects usually move reusable logic into modules that can be tested without running the program.

A build file may combine several test binaries under one command:

const test_step = b.step("test", "Run all tests");

const unit_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    }),
});
test_step.dependOn(&b.addRunArtifact(unit_tests).step);

const cli_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    }),
});
test_step.dependOn(&b.addRunArtifact(cli_tests).step);

Now one command runs both:

zig build test

Testing in Zig is plain. There is no separate test language. A test is a block of Zig code. It imports the same modules, uses the same allocator rules, and returns errors in the same way as ordinary code.

Exercise 15-17. Add a test block for a function that returns the larger of two integers.

Exercise 15-18. Use std.testing.expectEqual instead of expect.

Exercise 15-19. Write a test that allocates an ArrayList and releases it.

Exercise 15-20. Add a test step to build.zig.