Overview
Good tests are short, precise, and mean what they say. Zig’s std.testing makes this easy with small, composable assertions (expect, expectEqual, expectError) and a built-in testing allocator that detects leaks by default. Combined with allocation-failure injection, you can exercise error paths that would otherwise be hard to trigger, ensuring your code releases resources correctly and deterministically; see 10 and testing.zig.
This chapter shows how to write expressive tests, how to interpret the test runner’s leak diagnostics, and how to use std.testing.checkAllAllocationFailures to bulletproof code against error.OutOfMemory without writing hundreds of bespoke tests; see 11 and heap.zig.
Learning Goals
Testing basics with std.testing
Zig’s test runner discovers test blocks in any file you pass to zig test. Assertions are ordinary functions that return errors, so they compose naturally with try/catch.
The std.testing Module Structure
Before diving into specific assertions, it is helpful to see the complete toolkit available in std.testing. The module provides three categories of functionality: assertion functions, test allocators, and utilities.
This chapter focuses on the core assertions (expect, expectEqual, expectError) and the test allocators for leak detection. Additional assertion functions like expectEqualSlices and expectEqualStrings provide specialized comparisons, while utilities like tmpDir() help test filesystem code; see testing.zig.
Expectations: booleans, equality, and errors
This example covers boolean assertions, value equality, string equality, and expecting an error from a function under test.
const std = @import("std");
/// Performs exact integer division, returning an error if the divisor is zero.
/// This function demonstrates error handling in a testable way.
fn divExact(a: i32, b: i32) !i32 {
// Guard clause: check for division by zero before attempting division
if (b == 0) return error.DivideByZero;
// Safe to divide: use @divTrunc for truncating integer division
return @divTrunc(a, b);
}
test "boolean and equality expectations" {
// Test basic boolean expression using expect
// expect() returns an error if the condition is false
try std.testing.expect(2 + 2 == 4);
// Test type-safe equality with expectEqual
// Both arguments must be the same type; here we explicitly cast to u8
try std.testing.expectEqual(@as(u8, 42), @as(u8, 42));
}
test "string equality (bytes)" {
// Define expected string as a slice of const bytes
const expected: []const u8 = "hello";
// Create actual string via compile-time concatenation
// The ++ operator concatenates string literals at compile time
const actual: []const u8 = "he" ++ "llo";
// Use expectEqualStrings for slice comparison
// This compares the content of the slices, not just the pointer addresses
try std.testing.expectEqualStrings(expected, actual);
}
test "expecting an error" {
// Test that divExact returns the expected error when dividing by zero
// expectError() succeeds if the function returns the specified error
try std.testing.expectError(error.DivideByZero, divExact(1, 0));
// Test successful division path
// We use 'try' to unwrap the success value, then expectEqual to verify it
// If divExact returns an error here, the test will fail
try std.testing.expectEqual(@as(i32, 3), try divExact(9, 3));
}
$ zig test basic_tests.zigAll 3 tests passed.Leak detection by construction
The testing allocator (std.testing.allocator) is a GeneralPurposeAllocator configured to track allocations and report leaks when a test finishes. That means your tests fail if they forget to free; see 10.
How Test Allocators Work
The testing module provides two allocators: allocator for general testing with leak detection, and failing_allocator for simulating allocation failures. Understanding their architecture helps explain their different behaviors.
The testing.allocator wraps a GeneralPurposeAllocator configured with stack traces and leak detection. The failing_allocator uses a FixedBufferAllocator as its base, then wraps it with failure injection logic. Both expose the standard Allocator interface, making them drop-in replacements for production allocators in tests; see testing.zig.
What a leak looks like
The test below intentionally forgets to free. The runner reports a leaked address, a stack trace to the allocating callsite, and exits with a non-zero status.
const std = @import("std");
// This test intentionally leaks to demonstrate the testing allocator's leak detection.
// Do NOT copy this pattern into real code; see leak_demo_fix.zig for the fix.
test "leak detection catches a missing free" {
const allocator = std.testing.allocator;
// Intentionally leak this allocation by not freeing it.
const buf = try allocator.alloc(u8, 64);
// Touch the memory so optimizers can't elide the allocation.
for (buf) |*b| b.* = 0xAA;
// No free on purpose:
// allocator.free(buf);
}
$ zig test leak_demo_fail.zig[gpa] (err): memory address 0x… leaked:
… leak_demo_fail.zig:1:36: … in test.leak detection catches a missing free (leak_demo_fail.zig)
All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1:
…/test --seed=0x…The "All N tests passed." line only asserts test logic; the leak report still causes the overall run to fail. Fix the leak to make the suite green. 04
Fixing leaks with defer
Use defer allocator.free(buf) immediately after a successful allocation to guarantee release along all paths.
const std = @import("std");
test "no leak when freeing properly" {
// Use the testing allocator, which tracks allocations and detects leaks
const allocator = std.testing.allocator;
// Allocate a 64-byte buffer on the heap
const buf = try allocator.alloc(u8, 64);
// Schedule deallocation to happen at scope exit (ensures cleanup)
defer allocator.free(buf);
// Fill the buffer with 0xAA pattern to demonstrate usage
for (buf) |*b| b.* = 0xAA;
// When the test exits, defer runs allocator.free(buf)
// The testing allocator verifies all allocations were freed
}
$ zig test leak_demo_fix.zigAll 1 tests passed.The Leak Detection Lifecycle
Leak detection happens automatically at the end of each test. Understanding this timeline helps explain why defer must execute before the test completes and why leak reports appear even when test assertions pass.
When a test ends, the GeneralPurposeAllocator verifies that all allocated memory has been freed. If any allocations remain, it prints the stack trace showing where the leaked memory was allocated (not where it should have been freed). This automatic checking eliminates entire categories of bugs without requiring manual tracking. The key is placing defer allocator.free(…) immediately after successful allocation so it executes on all code paths, including early returns and error propagation; see heap.zig.
Allocation-failure injection
Code that allocates memory must be correct even when allocations fail. std.testing.checkAllAllocationFailures reruns your function with a failing allocator at each allocation site, verifying you clean up partially-initialized state and propagate error.OutOfMemory properly; see 10.
Systematically testing for OOM safety
This example uses checkAllAllocationFailures with a small function that performs two allocations and frees both with defer. The helper simulates failure at each allocation point; the test passes only if no leaks occur and error.OutOfMemory is forwarded correctly.
const std = @import("std");
fn testImplGood(allocator: std.mem.Allocator, length: usize) !void {
const a = try allocator.alloc(u8, length);
defer allocator.free(a);
const b = try allocator.alloc(u8, length);
defer allocator.free(b);
}
// No "bad" implementation here; see leak_demo_fail.zig for a dedicated failing example.
test "OOM injection: good implementation is leak-free" {
const allocator = std.testing.allocator;
try std.testing.checkAllAllocationFailures(allocator, testImplGood, .{32});
}
// Intentionally not included: a "bad" implementation under checkAllAllocationFailures
// will cause the test runner to fail due to leak logging, even if you expect the error.
// See leak_demo_fail.zig for a dedicated failing example.
$ zig test oom_injection.zigAll 1 tests passed.A deliberately "bad" implementation under checkAllAllocationFailures will cause the test runner to record leaked allocations and fail the overall run, even if you expectError(error.MemoryLeakDetected, …). Keep failing examples isolated when teaching or debugging; see 10.
Notes & Caveats
- The testing allocator is only available when compiling tests. Attempting to use it in non-test code triggers a compile error.
- Leak detection relies on deterministic deallocation. Prefer
deferdirectly after allocation; avoid hidden control flow that skips frees; see 04. - For integration tests that need lots of allocations, wrap with an arena allocator for speed, but still route ultimate backing through the testing allocator to preserve leak checks; see 10.
Exercises
- Write a function that builds a
std.ArrayList(u8)from input bytes, then clears it. UsecheckAllAllocationFailuresto verify OOM safety; see 11. - Introduce a deliberate early return after the first allocation and watch the leak detector catch a missing
free; then fix it withdefer. - Add
expectErrortests for a function that returns an error on invalid input; include both the erroring and the successful path.
Alternatives & Edge Cases
- If you need to run a suite that intentionally demonstrates leaks, keep those files separate from your passing tests to avoid failing CI runs. Alternatively, gate them behind a build flag and only opt in locally; see 20.
- Outside of tests, you can enable
std.heap.GeneralPurposeAllocatorleak detection in debug builds to catch leaks in manual runs, but production builds should disable expensive checks for performance. - Allocation-failure injection is most effective on small, self-contained helpers. For higher-level workflows, test critical components in isolation to keep the induced failure space manageable; see 37.