Skip to content

Running Tests

Zig has built-in support for tests.

Zig has built-in support for tests.

You can test one file directly with:

zig test src/main.zig

That command compiles the file, finds its test blocks, and runs them.

Inside a project, you usually run tests through build.zig:

zig build test

This gives the project one standard test command.

A Simple Test

A Zig test uses a test block:

const std = @import("std");

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

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

The test name is:

"add returns the sum"

The check is:

try std.testing.expect(add(2, 3) == 5);

If the expression is true, the test passes.

If it is false, the test fails.

Testing a File Directly

For a small file, use:

zig test src/main.zig

This is useful while learning because it avoids writing a build file.

But real projects usually need more:

target options
optimization options
dependencies
test filters
custom test steps
integration tests

That is where build.zig becomes useful.

Adding Tests to build.zig

A common test setup looks like this:

const std = @import("std");

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

    const unit_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });

    const run_unit_tests = b.addRunArtifact(unit_tests);

    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_unit_tests.step);
}

Now this command works:

zig build test

The build file creates a test artifact, creates a run step for it, and connects that run step to the named test step.

The Three Pieces

This line compiles the tests:

const unit_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    }),
});

This line creates a command that runs the test artifact:

const run_unit_tests = b.addRunArtifact(unit_tests);

This creates the public build command:

const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);

So the command:

zig build test

means:

compile the tests
run the tests
finish the test step

Tests in Separate Files

Many projects keep tests close to the code.

For example:

src/
  math.zig
  main.zig

src/math.zig:

const std = @import("std");

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

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

src/main.zig:

const std = @import("std");
const math = @import("math.zig");

pub fn main() void {
    std.debug.print("{}\n", .{math.add(2, 3)});
}

If you test only src/main.zig, Zig will compile the imports needed by main.zig. But test discovery depends on the test root.

For libraries, it is common to have a root file that imports the modules you want tested.

Example src/root.zig:

pub const math = @import("math.zig");

test {
    _ = math;
}

Then build.zig can test src/root.zig.

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

This gives the test runner a clear root for the package.

Testing Libraries

If your project builds a library, tests often target the library root:

const lib_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
    }),
});

const run_lib_tests = b.addRunArtifact(lib_tests);

const test_step = b.step("test", "Run library tests");
test_step.dependOn(&run_lib_tests.step);

This is better than testing an executable entry point when most logic lives in library modules.

A good project often keeps main.zig small and puts testable logic in separate modules.

Testing Executables

You can also test code used by an executable.

Suppose your executable uses src/main.zig as its root. Then:

const exe_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    }),
});

This is acceptable for small programs.

For larger programs, split the core logic into a library module and keep main.zig focused on startup, argument parsing, and wiring.

Test Dependencies

If your application uses external dependencies, tests may need them too.

Adding a dependency to the executable does not automatically add it to the test artifact.

For example:

const parser = b.dependency("parser", .{
    .target = target,
    .optimize = optimize,
});

exe.root_module.addImport("parser", parser.module("parser"));

If tests also import parser, add it to the test root module:

unit_tests.root_module.addImport("parser", parser.module("parser"));

Each root module has its own imports.

This is a common source of beginner errors.

Filtering Tests

When using zig test directly, you can often pass a test filter to run matching tests.

For example:

zig test src/root.zig --test-filter add

This runs tests whose names match the filter.

In a build file, you can expose a filter as a build option:

const test_filter = b.option(
    []const u8,
    "test-filter",
    "Skip tests that do not match filter",
);

Then pass it to the test artifact:

if (test_filter) |filter| {
    unit_tests.filter = filter;
}

Now you can run:

zig build test -Dtest-filter=add

This is useful when a project has many tests and you want to focus on one area.

Tests with Build Options

Tests can receive build options just like executables.

const options = b.addOptions();
options.addOption(bool, "enable_logging", true);

unit_tests.root_module.addOptions("build_options", options);

Then test code can import:

const build_options = @import("build_options");

This lets tests run under the same configuration as the program.

For example, you might test with:

zig build test -Denable-logging=true

or:

zig build test -Doptimize=ReleaseSafe

Testing in Different Modes

Tests usually run in Debug mode by default.

You can test release behavior too:

zig build test -Doptimize=ReleaseSafe
zig build test -Doptimize=ReleaseFast
zig build test -Doptimize=ReleaseSmall

This can reveal bugs that only appear under optimization.

A practical workflow is:

zig build test
zig build test -Doptimize=ReleaseSafe
zig build -Doptimize=ReleaseFast

Use Debug while developing. Use ReleaseSafe before shipping. Use ReleaseFast for final performance builds when appropriate.

Cross-Target Tests

Tests can be compiled for another target:

zig build test -Dtarget=x86_64-linux

But running them is different.

If you are not on Linux, your machine may not be able to execute the Linux test binary. Cross compilation can produce the test artifact, but execution needs a compatible runtime, emulator, device, or operating system.

So keep this distinction clear:

compile tests for a target
run tests on a target

They are separate operations.

Integration Tests

Unit tests check small pieces of code.

Integration tests check larger behavior: running a program, reading files, talking to a local server, parsing real input, or checking command-line output.

A simple integration test can be another Zig file:

tests/
  cli_test.zig

In build.zig:

const integration_tests = b.addTest(.{
    .root_module = b.createModule(.{
        .root_source_file = b.path("tests/cli_test.zig"),
        .target = target,
        .optimize = optimize,
    }),
});

const run_integration_tests = b.addRunArtifact(integration_tests);

test_step.dependOn(&run_integration_tests.step);

Now:

zig build test

runs both unit tests and integration tests, because both run steps are dependencies of the same test step.

A Complete Test Setup

Here is a compact project-style setup:

const std = @import("std");

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

    const exe = b.addExecutable(.{
        .name = "app",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });

    b.installArtifact(exe);

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

    const run_unit_tests = b.addRunArtifact(unit_tests);

    const integration_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("tests/integration.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });

    const run_integration_tests = b.addRunArtifact(integration_tests);

    const test_step = b.step("test", "Run all tests");
    test_step.dependOn(&run_unit_tests.step);
    test_step.dependOn(&run_integration_tests.step);
}

This gives one command:

zig build test

and that command runs all tests connected to the test step.

Common Mistakes

A common mistake is creating a test artifact but never running it.

This compiles tests:

const unit_tests = b.addTest(.{ ... });

But you still need:

const run_unit_tests = b.addRunArtifact(unit_tests);

and:

test_step.dependOn(&run_unit_tests.step);

Another mistake is forgetting imports on the test root module.

If the executable has:

exe.root_module.addImport("parser", parser.module("parser"));

the test artifact may also need:

unit_tests.root_module.addImport("parser", parser.module("parser"));

Another mistake is testing only main.zig when the real logic lives elsewhere. Prefer testing the library root or specific modules.

The Important Idea

Testing in Zig has two levels.

For one file, use:

zig test file.zig

For a project, define a test step in build.zig:

const unit_tests = b.addTest(.{ ... });
const run_unit_tests = b.addRunArtifact(unit_tests);

const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_unit_tests.step);

Then use:

zig build test

That gives your project a stable, repeatable test command.