zig-testing-integrado

Testing in Zig: integrated tests within the language

  • 4 min

Zig’s integrated testing is a testing system that lives within the language itself and is executed with zig test. In many languages, tests depend on external frameworks; Zig brings them built-in.

  • In Java/C++: You have to create a tests/ folder parallel to src/, configure CMake or Maven, and struggle with visibility (how do I test this private function?).
  • In Python/JS: You need to install external frameworks (PyTest, Jest) and configure the runner.

In Zig, Testing is part of the language.

Zig starts from a simple philosophy: If writing a test is difficult or tedious, you won’t do it. Therefore, Zig lets you write tests in the same file as your source code, right next to the function you are testing.

The test block

To write a test, we use the keyword test, a descriptive name, and a block of code.

const std = @import("std");

// Our business logic function
fn add(a: i32, b: i32) i32 {
    return a + b;
}

// The test can live right here.
test "add works with positives" {
    const result = add(2, 2);
    
    // We use 'expect' to validate
    try std.testing.expect(result == 4);
}

test "add works with negatives" {
    try std.testing.expect(add(-1, -1) == -2);
}
Copied!

To run these tests, we don’t compile the normal program. We use a special command:

zig test main.zig
Copied!

If everything goes well, you will see: All 2 tests passed.

Tests are not part of the normal executable

When you run zig build-exe or zig build-obj, the test declarations are omitted from the normal compilation. They are analyzed when building tests via zig test or a test step in build.zig.

The std.testing namespace

The standard library offers us several useful assertions. All of them return an error if they fail, so we must mark our tests with try.

  • expect(condition): The most basic one. Fails if it is false.
  • expectEqual(expected, actual): Checks equality. Useful because if it fails, it tells you “Expected 5, found 4”.
  • expectError(expected_error, action): Very useful for testing our !T error system.
  • expectEqualStrings(str1, str2): For comparing text slices.
test "validate errors" {
    const result = functionThatFails();
    try std.testing.expectError(error.Oops, result);
}
Copied!

Testing private functions

In Zig, because the test is in the same file (and therefore in the same scope), tests can access private functions (fn without pub).

// Private function (not exported)
fn complexInternalLogic(x: u32) u32 {
    return x * 2;
}

// Public function
pub fn publicApi(x: u32) u32 {
    return complexInternalLogic(x) + 1;
}

test "internal logic behaves correctly" {
    // We can call the private function directly
    try std.testing.expectEqual(4, complexInternalLogic(2));
}
Copied!

This allows checking the implementation without making it part of the public API.

Memory leak detection

Do you remember the DebugAllocator we saw in the memory block?

Zig does not allocate memory for us inside tests. When a test needs an allocator, we can use std.testing.allocator, which detects leaks from allocations made through it.

test "detect leak" {
    // We use the testing allocator
    const list = try std.testing.allocator.create(i32);
    list.* = 10;
    
    // Oops, we forgot: std.testing.allocator.destroy(list);
    // The test will pass the assertions, BUT Zig will fail at the end.
}
Copied!

Output:

error: memory leaked
... trace of where the memory was allocated ...
Copied!

This way, unit tests can also verify memory ownership and deallocation.

Doctests

A doctest is a test block whose name is the identifier of another declaration, without quotes. It serves as a verifiable example and appears associated with that declaration in the generated documentation.

/// Adds one to the received value.
pub fn addOne(value: i32) i32 {
    return value + 1;
}

test addOne {
    try std.testing.expectEqual(42, addOne(41));
}
Copied!

Tests in projects with multiple files

If you have many files, you don’t want to run zig test file1.zig, then file2.zig, etc.

Zig analyzes declarations lazily. To make the root test compilation discover other files, we can import them from an unnamed test block.

main.zig:

test {
    // This forces the compiler to look inside these files
    // and find their test blocks.
    _ = @import("models/user.zig");
    _ = @import("utils/math.zig");
    
}
Copied!