Skip to content

Zig Test Framework

Zig has a built-in test system. You do not need a separate testing library to start writing tests.

Zig has a built-in test system. You do not need a separate testing library to start writing tests.

A test is written with a test block:

const std = @import("std");

test "addition works" {
    try std.testing.expect(1 + 1 == 2);
}

Run it with:

zig test main.zig

Zig compiles the file, finds the test blocks, builds a temporary test program, runs it, and reports whether the tests passed. The default test runner comes from Zig’s standard library, and zig test can test a single file directly.

What a Test Block Is

A test block is code that runs only when you run tests.

This is normal program code:

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

This is test code:

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

The test block checks that add(2, 3) returns 5.

The string after test is the test name. Use a plain sentence. It should say what behavior you are checking.

Good:

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

Weak:

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

A test name is not for the compiler. It is for the person reading the test output.

The Smallest Useful Test

Here is a complete file:

const std = @import("std");

fn double(n: i32) i32 {
    return n * 2;
}

test "double multiplies a number by two" {
    try std.testing.expect(double(4) == 8);
}

Run it:

zig test main.zig

The test passes because double(4) returns 8.

Now break the function:

fn double(n: i32) i32 {
    return n + 2;
}

The test fails because double(4) now returns 6, not 8.

That is the basic idea of testing: write a small program that proves another piece of code behaves the way you expect.

std.testing.expect

The most common testing function is:

std.testing.expect(condition)

It expects a boolean value.

If the condition is true, the test continues.

If the condition is false, the test fails.

Because expect can fail, you call it with try:

try std.testing.expect(x == 10);

Read this as:

“Expect x == 10. If the expectation fails, return the test failure.”

Example:

const std = @import("std");

fn isEven(n: i32) bool {
    return n % 2 == 0;
}

test "isEven returns true for even numbers" {
    try std.testing.expect(isEven(10));
}

test "isEven returns false for odd numbers" {
    try std.testing.expect(!isEven(7));
}

The ! operator means “not.” So !isEven(7) means “7 is not even.”

Use More Specific Expectations

expect is enough for many tests, but Zig also provides more specific helpers in std.testing.

For comparing values, use expectEqual:

const std = @import("std");

fn square(n: i32) i32 {
    return n * n;
}

test "square returns the number multiplied by itself" {
    try std.testing.expectEqual(@as(i32, 25), square(5));
}

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

This order matters:

try std.testing.expectEqual(expected, actual);

For strings or byte slices, use expectEqualStrings:

const std = @import("std");

fn greeting() []const u8 {
    return "hello";
}

test "greeting returns hello" {
    try std.testing.expectEqualStrings("hello", greeting());
}

This gives better failure messages than a plain boolean comparison.

Tests Can Return Errors

A test block can use try, so it can call functions that return errors.

Example:

const std = @import("std");

fn parsePort(text: []const u8) !u16 {
    return try std.fmt.parseInt(u16, text, 10);
}

test "parsePort parses a valid port number" {
    const port = try parsePort("8080");
    try std.testing.expectEqual(@as(u16, 8080), port);
}

The function parsePort can fail if the text is not a valid number.

Inside the test, try parsePort("8080") means:

If parsing succeeds, store the result in port.

If parsing fails, fail the test.

This keeps test code short without hiding the possibility of failure.

Testing Errors

Sometimes success means a function must return an error.

Example:

const std = @import("std");

const ParseError = error{
    EmptyInput,
};

fn firstByte(text: []const u8) !u8 {
    if (text.len == 0) {
        return ParseError.EmptyInput;
    }

    return text[0];
}

test "firstByte fails on empty input" {
    try std.testing.expectError(ParseError.EmptyInput, firstByte(""));
}

expectError checks that a function returns the exact error you expect.

This is important because error behavior is part of your API. A function should fail in a clear and predictable way.

Testing with Allocators

Many Zig functions need memory. In tests, use std.testing.allocator.

Example:

const std = @import("std");

fn makeNumbers(allocator: std.mem.Allocator) ![]i32 {
    const numbers = try allocator.alloc(i32, 3);
    numbers[0] = 10;
    numbers[1] = 20;
    numbers[2] = 30;
    return numbers;
}

test "makeNumbers creates three numbers" {
    const allocator = std.testing.allocator;

    const numbers = try makeNumbers(allocator);
    defer allocator.free(numbers);

    try std.testing.expectEqual(@as(usize, 3), numbers.len);
    try std.testing.expectEqual(@as(i32, 10), numbers[0]);
    try std.testing.expectEqual(@as(i32, 20), numbers[1]);
    try std.testing.expectEqual(@as(i32, 30), numbers[2]);
}

Notice the cleanup:

defer allocator.free(numbers);

This frees the memory when the test exits.

In tests, memory cleanup matters. If your test allocates memory and does not free it, the test runner can help reveal the leak. Zig 0.16 release notes also recommend using testing-specific resources such as std.testing.io when testing I/O code, similar to using std.testing.allocator for memory.

Tests Are Close to the Code

A common Zig style is to put tests in the same file as the code they test.

Example:

const std = @import("std");

pub fn clamp(value: i32, min: i32, max: i32) i32 {
    if (value < min) return min;
    if (value > max) return max;
    return value;
}

test "clamp returns the value when it is inside the range" {
    try std.testing.expectEqual(@as(i32, 5), clamp(5, 0, 10));
}

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

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

This makes the file self-checking. When someone changes clamp, the tests are right there.

Testing Public APIs

When a file exposes public functions, tests should focus on behavior, not implementation details.

Suppose this is your function:

pub fn max(a: i32, b: i32) i32 {
    if (a > b) return a;
    return b;
}

A useful test checks the visible result:

test "max returns the larger number" {
    try std.testing.expectEqual(@as(i32, 9), max(9, 3));
    try std.testing.expectEqual(@as(i32, 9), max(3, 9));
}

Do not test the exact internal branch structure. Test what the function promises to do.

That distinction matters. Good tests let you rewrite the inside of a function without breaking the tests, as long as the behavior stays correct.

Testing Edge Cases

Beginners often test only the obvious case. That is useful, but incomplete.

For a function like this:

pub fn abs(n: i32) i32 {
    if (n < 0) return -n;
    return n;
}

You should test positive, negative, and zero:

test "abs handles positive numbers" {
    try std.testing.expectEqual(@as(i32, 7), abs(7));
}

test "abs handles negative numbers" {
    try std.testing.expectEqual(@as(i32, 7), abs(-7));
}

test "abs handles zero" {
    try std.testing.expectEqual(@as(i32, 0), abs(0));
}

Edge cases are values near boundaries: zero, empty strings, empty slices, one item, maximum values, minimum values, invalid input, duplicate input, and unusual but allowed input.

Many bugs live at boundaries.

Test Blocks Can Be Anonymous

A test can have a name:

test "something useful" {
    // ...
}

It can also be anonymous:

test {
    // ...
}

Named tests are better for beginner code. They produce clearer output and make the purpose of the test obvious.

Use anonymous tests mainly for simple compile-time checks or internal declaration references.

Testing from a Build Script

For a single file, this is enough:

zig test main.zig

For a larger project, tests usually run through build.zig.

The build system can create a test compile step and a test run step. Zig’s build system documentation notes that compiling tests and running tests are separate build graph steps, so a build script needs to wire them together when orchestrating tests.

You will learn the full build-system version later. For now, remember the simple rule:

Use zig test file.zig while learning.

Use zig build test when working inside a real project that defines a test step.

A Good First Testing Habit

When you write a function, add one small test immediately.

Do this:

pub fn addOne(n: i32) i32 {
    return n + 1;
}

test "addOne adds one to the input" {
    try std.testing.expectEqual(@as(i32, 42), addOne(41));
}

Then run:

zig test main.zig

This gives you a tight loop:

write code

write a test

run the test

fix the code

run the test again

That loop is one of the best ways to learn Zig. The compiler checks your types. The test runner checks your behavior. Together, they give you fast feedback while the program is still small.