⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

Testing

const std = @import("std");

test "expectEqual prints both values on failure" {
    // expect(a == b) only tells you "false". expectEqual tells you
    // expected 42, found 41. The expected value comes first.
    try std.testing.expectEqual(42, 41 + 1);
    try std.testing.expectEqual(@as(?u8, null), null);
}

test "slice and string comparisons show where they diverge" {
    try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, &.{ 1, 2, 3 });

    // On failure this prints both strings and the first differing index.
    try std.testing.expectEqualStrings("hello", "hel" ++ "lo");

    try std.testing.expectStringStartsWith("zig build verify", "zig build");
}

fn parseDigit(c: u8) !u4 {
    if (c < '0' or c > '9') return error.NotADigit;
    return @intCast(c - '0');
}

test "expectError asserts the failure path" {
    // Untested error paths rot. This makes them first-class assertions.
    try std.testing.expectError(error.NotADigit, parseDigit('x'));
    try std.testing.expectEqual(7, try parseDigit('7'));
}

test "floats compare within a tolerance" {
    const third: f64 = 1.0 / 3.0;
    // Never == on computed floats; state how close is close enough.
    try std.testing.expectApproxEqAbs(0.333, third, 0.001);
    try std.testing.expectApproxEqRel(1.0, third * 3.0, std.math.floatEps(f64));
}

const Config = struct {
    name: []const u8,
    retries: u8,
};

test "expectEqualDeep follows pointers and slices" {
    const a = Config{ .name = "prod", .retries = 3 };
    const b = Config{ .name = "prod", .retries = 3 };

    // expectEqual on these would compare the slice pointers.
    // expectEqualDeep compares what they point at.
    try std.testing.expectEqualDeep(a, b);
}

test "expectFmt checks formatted output" {
    try std.testing.expectFmt("0x00ff", "0x{x:0>4}", .{255});
}

test "the test allocator reports leaks" {
    // std.testing.allocator fails the test if anything is still
    // allocated when the test returns. Forget this free and the test
    // fails with a stack trace of the leaked allocation.
    const gpa = std.testing.allocator;
    const buf = try gpa.alloc(u8, 64);
    defer gpa.free(buf);

    // It also detects double-free and use-after-free in test builds.
    try std.testing.expect(buf.len == 64);
}

expect tells you nothing; the others tell you what

try std.testing.expect(a == b) fails with “expected true, found false” and leaves you to add print statements. The typed assertions carry the values into the failure message:

AssertionReports on failure
expectEqual(expected, actual)both values
expectEqualStringsboth strings and the first differing byte
expectEqualSlices(T, ...)both slices and the diverging index
expectError(err, expr)which error, or that none was returned

The expected value comes first by convention. expectEqual infers its type from that first argument, which is why @as(?u8, null) sometimes needs the cast: it tells the comparison what type null is.

Assert the error paths too

Untested failure paths are where bugs hide. expectError makes “this input is rejected” a first-class assertion, as checkable as the success case. A parser test should pin both the digit it accepts and the character it refuses.

Floats need a tolerance

Never compare computed floats with ==. expectApproxEqAbs takes an absolute tolerance for values near a known magnitude; expectApproxEqRel takes a relative one, and std.math.floatEps(f64) is the natural bound for “as close as the type allows.”

expectEqualDeep for structures

expectEqual on two structs holding slices compares the slice pointers, which is almost never what you meant. expectEqualDeep follows pointers and slices and compares the pointed-at content.

The allocator is a test

std.testing.allocator fails the test if anything it handed out is still live when the test returns. A missing free is therefore a caught bug rather than a slow leak in production. It also panics on a double free, on a free through the wrong allocator, and on most writes after free. Wire every allocation to a defer free and let the allocator police it.

Using it everywhere is the single highest-value habit in this section. Memory bugs are the ones that do not reproduce, do not show up in the test that caused them, and turn into a production incident weeks later. A test that uses this allocator is also a leak test, at no cost in code.

std.testing.checkAllAllocationFailures goes further: it runs your function repeatedly, failing the allocator at a different call each time, and checks that every partial failure still cleans up. You would not write that by hand, and it is the only way to find out whether your errdefer ladder is right.

How tests are found and run

A test block is a top-level declaration, and zig test compiles the file into a runner that executes every one it can see. “Can see” is the part with a rule behind it. Tests in a file that nothing imports are never compiled, so a test file added to the tree does not run until something reaches it.

std.testing.refAllDecls(@This()) at the bottom of a file forces every declaration to be analysed. That pulls in the tests of imported files, and also catches code that no longer compiles but that nothing currently calls.

zig build test --test-filter <substring> runs the subset whose names match, which is what you want when iterating on one failure. Test names are plain strings, so writing them as sentences that describe the behaviour helps here: filtering on "rejects" finds every negative case.

Tests are documentation that cannot go stale

A test next to the function it exercises is the usage example, and unlike a comment it fails the build when it stops being true. The standard library is full of them for that reason, and it is worth copying. A reader arriving at an unfamiliar function will read the test before the doc comment, because the test cannot be lying.

The same principle runs this entire site. Every snippet here is compiled and run before the page is published: the same guarantee, at a larger scale. See how this guide is verified.

Skipping and expected failures

return error.SkipZigTest skips a test at run time. Use it for a case that only applies on some targets. The runner reports it as skipped rather than passed, so a permanently skipped test is visible rather than silently absent.

For code that should not compile, there is no assertion to write. The compile error already fails the build. Testing that a misuse is rejected is done by not writing it, and by trusting the type.