Skip to content
Make room for learningYour learning studio
Course outline

Testing

Zig has testing built into the language. Test blocks live alongside your code, run with zig test, and have access to the standard testing library for assertions and memory leak detection.

What You'll Learn

By the end of this lesson you will write the three assertions that make a result trustworthy: one that a call returned the value you expected, one that a call failed with the specific error you expected, and one that nothing was left allocated when it finished. You will also read a real failure report and work out from it which assertion broke and what it saw.

This is the milestone the capstone's Record Analyzer calls its error pipeline. The analyzer skips lines it cannot parse, counts the rejections and reports them at the end — behaviour that is only worth anything if the failure paths are tested as carefully as the successful ones. You arrive here able to allocate through an explicit allocator and release it with defer (Memory and Allocators), and to propagate a failure with try and inspect it with catch (Error Handling); what is new is turning those into assertions a machine checks for you every time you build.

Test Blocks

Tests are declared with the test keyword and live anywhere in your source files. Here is a complete file — two functions and two tests over them — exactly as you would save it:

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

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

test "add positive numbers" {
    try std.testing.expectEqual(@as(i32, 5), add(2, 3));
}

test "add negatives" {
    try std.testing.expectEqual(@as(i32, 0), add(-1, 1));
}

There is no main in that file, and it needs none. Run it with zig test add.zig and the test runner reports:

1/2 add.test.add positive numbers...OK
2/2 add.test.add negatives...OK
All 2 tests passed.

Now break the first assertion on purpose — change the expected 5 to a 6 — and the same command reports something far more useful than a boolean:

1/2 add.test.add positive numbers...expected 6, found 5
FAIL (TestExpectedEqual)
/opt/zig-x86_64-linux-0.16.0/lib/std/testing.zig:118:17: 0x1238174 in expectEqualInner__anon_43332 (std.zig)
                return error.TestExpectedEqual;
                ^
/opt/zig-x86_64-linux-0.16.0/lib/std/testing.zig:83:5: 0x1238c3b in expectEqual (add.zig)
    return expectEqualInner(T, expected, actual);
    ^
/tmp/w/add.zig:8:5: 0x1238c82 in test.add positive numbers (add.zig)
    try std.testing.expectEqual(@as(i32, 6), add(2, 3));
    ^
2/2 add.test.add negatives...OK
1 passed; 0 skipped; 1 failed.
error: the following test command failed with exit code 1:
/.cache/zig/o/8f06f46a0b807478362fde7d1caf6efc/test --seed=0x4add7311

That is the output as printed, nothing removed — captured on a machine where the file sat in /tmp/w, so your own paths will be wherever you saved it. A few other parts are noise you should learn to ignore: the hex addresses, the cache hash and the --seed value change on every run, and the __anon_43332 suffix is a generated name for the specialization expectEqual produced for i32 — the number will differ for you and means nothing.

What is left names everything you need. Read it from the bottom up: one test failed out of two; the process exited non-zero, announced by the final error: line, so a build pipeline will notice; the failure was error.TestExpectedEqual; the two testing.zig frames are std's own assertion machinery producing that error; and the frame that is yours — the deepest one with your filename — puts the failing assertion on line 8. The most useful line is the first: expected 6, found 5 tells you the comparison's two sides before you read a single frame. The second test still ran, because a failing test stops that test, not the run.

The playground below cannot show you this. It executes your file with zig run, which compiles main and ignores test blocks entirely; only zig test builds the test runner. So the runnable version below demonstrates the same two functions through main and prints the comparisons by hand. Treat it as a stand-in for the report above, not as how you would actually write this.

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

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

fn factorial(n: u32) u64 {
    if (n == 0) return 1;
    var result: u64 = 1;
    var i: u32 = 1;
    while (i <= n) : (i += 1) {
        result *= i;
    }
    return result;
}

pub fn main() !void {
    // Demonstrate the functions
    std.debug.print("add(2, 3) = {}\n", .{add(2, 3)});
    std.debug.print("add(-1, 1) = {}\n", .{add(-1, 1)});
    std.debug.print("factorial(5) = {}\n", .{factorial(5)});
    std.debug.print("factorial(0) = {}\n", .{factorial(0)});
    std.debug.print("factorial(10) = {}\n", .{factorial(10)});

    // Stand-ins for the assertions the test blocks above make. A printed
    // "true" is not an assertion — nothing fails when it turns false.
    std.debug.print("\nAssertions:\n", .{});
    std.debug.print("add(2,3) == 5: {}\n", .{add(2, 3) == 5});
    std.debug.print("factorial(5) == 120: {}\n", .{factorial(5) == 120});
    std.debug.print("factorial(0) == 1: {}\n", .{factorial(0) == 1});
}

std.testing Assertions

The standard library provides typed assertion functions. Each one takes what you expect and what you got, and each is called with try:

zig
test "clamp and contains" {
    // expectEqual — two values must be equal. The @as pins the expected
    // value's type so the comparison is between two i32s, not an i32 and a
    // comptime_int the compiler would have to guess a type for.
    try std.testing.expectEqual(@as(i32, 5), clamp(5, 0, 10));

    // expect — a plain boolean condition must hold.
    try std.testing.expect(contains("hello world", "world"));

    // expectEqualStrings — like expectEqual but for []const u8, and it prints
    // the two strings side by side on failure instead of two pointer values.
    try std.testing.expectEqualStrings("Hello", greeting);

    // expectEqualSlices — element-by-element, with the element type named.
    try std.testing.expectEqualSlices(i32, &a, &b);
}

The specialised ones exist for their failure messages. expectEqual on two slices would compare and report pointers; expectEqualStrings reports the text and the index where they first diverged, which is the difference between a diagnosis and a puzzle.

The playground version below drives the same functions through main, printing each comparison rather than asserting it:

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

fn clamp(value: i32, min_val: i32, max_val: i32) i32 {
    if (value < min_val) return min_val;
    if (value > max_val) return max_val;
    return value;
}

fn contains(haystack: []const u8, needle: []const u8) bool {
    if (needle.len > haystack.len) return false;
    var i: usize = 0;
    while (i + needle.len <= haystack.len) : (i += 1) {
        if (std.mem.eql(u8, haystack[i..][0..needle.len], needle)) return true;
    }
    return false;
}

pub fn main() !void {
    // expectEqual — checks two values are equal
    std.debug.print("clamp(5, 0, 10) = {}\n", .{clamp(5, 0, 10)});
    std.debug.print("clamp(-5, 0, 10) = {}\n", .{clamp(-5, 0, 10)});
    std.debug.print("clamp(15, 0, 10) = {}\n", .{clamp(15, 0, 10)});

    // expect — checks a boolean condition
    std.debug.print("\ncontains(\"hello world\", \"world\") = {}\n", .{contains("hello world", "world")});
    std.debug.print("contains(\"hello\", \"xyz\") = {}\n", .{contains("hello", "xyz")});

    // expectEqualStrings — for string comparison
    const greeting = "Hello";
    std.debug.print("\nString comparison:\n", .{});
    std.debug.print("\"Hello\" == \"Hello\": {}\n", .{std.mem.eql(u8, greeting, "Hello")});

    // expectEqualSlices — for slice comparison
    const a = [_]i32{ 1, 2, 3 };
    const b = [_]i32{ 1, 2, 3 };
    std.debug.print("Slices equal: {}\n", .{std.mem.eql(i32, &a, &b)});
}

Every assertion above is written with try in front of it. That is not decoration — work out what it implies about a failing assertion before reading on.

Predict

This test block makes two assertions and the first one is wrong. What happens to the second assertion, and how does the test runner learn the test failed?

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

test "double" {
  try std.testing.expectEqual(@as(i32, 5), double(2));
  try std.testing.expectEqual(@as(i32, 8), double(4));
}

The first assertion returns error.TestExpectedEqual, try propagates it straight out of the test body, and the second assertion never runs. The runner marks the test failed for one reason: the test function returned an error instead of void. That is why every assertion needs try — these are ordinary error-returning functions, not magic. It also means a failing test shows you only its first failure, so the order you write assertions in decides what you see.

Testing with Allocators

The testing allocator detects memory leaks and use-after-free. std.testing.allocator is available only inside test blocks, and it does something no assertion you write can do: it checks, after the test body has finished, that everything the test allocated was given back.

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

const Bag = struct {
    items: std.ArrayList(i32) = .empty,

    fn add(self: *Bag, allocator: std.mem.Allocator, v: i32) !void {
        try self.items.append(allocator, v);
    }
};

test "bag leaks" {
    var bag: Bag = .{};
    try bag.add(std.testing.allocator, 10);
    try std.testing.expectEqual(@as(usize, 1), bag.items.items.len);
}

Every assertion in that test passes — the bag really does hold one item — and the test still fails. zig test bag.zig reports:

1/1 bag.test.bag leaks...OK
[DebugAllocator] (err): memory address 0x7f1636840000 leaked: 
/opt/zig-x86_64-linux-0.16.0/lib/std/array_list.zig:1235:56: 0x1239307 in ensureTotalCapacityPrecise (std.zig)
                const new_memory = try gpa.alignedAlloc(T, alignment, new_capacity);
                                                       ^
/opt/zig-x86_64-linux-0.16.0/lib/std/array_list.zig:1211:51: 0x1238f59 in ensureTotalCapacity (std.zig)
            return self.ensureTotalCapacityPrecise(gpa, growCapacity(new_capacity));
                                                  ^
/opt/zig-x86_64-linux-0.16.0/lib/std/array_list.zig:1265:41: 0x12385cd in addOne (std.zig)
            try self.ensureTotalCapacity(gpa, newlen);
                                        ^
/opt/zig-x86_64-linux-0.16.0/lib/std/array_list.zig:904:49: 0x1238467 in append (std.zig)
            const new_item_ptr = try self.addOne(gpa);
                                                ^
/tmp/w/bag.zig:7:30: 0x1238186 in add (bag.zig)
        try self.items.append(allocator, v);
                             ^
/tmp/w/bag.zig:13:16: 0x12382d0 in test.bag leaks (bag.zig)
    try bag.add(std.testing.allocator, 10);
               ^
...
(additional stack frames may have been skipped...)

All 1 tests passed.
1 errors were logged.
1 tests leaked memory.
error: the following test command failed with exit code 1:
/.cache/zig/o/680fe2be8624f8aa5530da93a368dbba/test --seed=0x10bea5f0

That is the real output, with one edit: the ... line stands in for four more frames — two in test_runner.zig and two in start.zig — that show the test runner and process startup calling into your test. They are always there and they never tell you anything about your bug, so this is the first thing to skip. As above, the paths beginning /tmp/w are just where this file happened to live; the addresses, the cache hash and the --seed value differ on every run and every machine. The frames and the summary lines do not.

Read the contradiction in that report carefully, because it is the whole point of this section: the line 1/1 ... OK and the line All 1 tests passed. are both true — every assertion held — and the run still failed, with a non-zero exit status announced by the final error: line, because of 1 tests leaked memory. The stack trace is not where the leak was detected; it is where the leaked memory was allocated, which is what you actually need.

Read it from the bottom of the frames upward and it walks you into your own code: the test called add, add called append — and everything above that is std's own machinery growing the list's buffer, append into addOne into ensureTotalCapacity into ensureTotalCapacityPrecise, which is where the bytes were finally requested. The frame you want is the deepest one whose path is yours, which here is bag.zig:7. The four array_list.zig frames above it are not suspects; they are how a correct append does its job. The fix is a defer bag.items.deinit(std.testing.allocator); right after the bag is created.

This is the one thing you get from std.testing.allocator for free and cannot get any other way: an assertion about memory that you never had to write. The playground below shows the same Stack type driven from main with a DebugAllocator, which is the closest a zig run program can come:

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

const Stack = struct {
    items: std.ArrayList(i32),
    allocator: std.mem.Allocator,

    fn init(allocator: std.mem.Allocator) Stack {
        return .{ .items = .empty, .allocator = allocator };
    }

    fn deinit(self: *Stack) void {
        self.items.deinit(self.allocator);
    }

    fn push(self: *Stack, value: i32) !void {
        try self.items.append(self.allocator, value);
    }

    fn pop(self: *Stack) ?i32 {
        if (self.items.items.len == 0) return null;
        return self.items.pop();
    }

    fn peek(self: *const Stack) ?i32 {
        if (self.items.items.len == 0) return null;
        return self.items.items[self.items.items.len - 1];
    }

    fn size(self: *const Stack) usize {
        return self.items.items.len;
    }
};

pub fn main() !void {
    // Using DebugAllocator (in tests you'd use std.testing.allocator)
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer {
        const check = gpa.deinit();
        if (check == .leak) {
            std.debug.print("Memory leak detected!\n", .{});
        } else {
            std.debug.print("No memory leaks!\n", .{});
        }
    }

    var stack = Stack.init(gpa.allocator());
    defer stack.deinit();

    // Test push and peek
    try stack.push(10);
    try stack.push(20);
    try stack.push(30);
    std.debug.print("Size after 3 pushes: {}\n", .{stack.size()});
    std.debug.print("Peek: {}\n", .{stack.peek().?});

    // Test pop
    const val = stack.pop().?;
    std.debug.print("Popped: {}\n", .{val});
    std.debug.print("Size after pop: {}\n", .{stack.size()});

    // Test pop until empty
    _ = stack.pop();
    _ = stack.pop();
    std.debug.print("Pop from empty: {?}\n", .{stack.pop()});
    std.debug.print("Peek empty: {?}\n", .{stack.peek()});
}

A leak-detecting allocator only helps if you actually let it check. The next program's values are all correct, yet it still reports a problem. Commit a hypothesis before you fix it.

Debug

Every number this program prints is correct, but the last line says a leak was detected. Say what is leaking and why before you change anything, then fix it so it reports no leaks.

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

const Bag = struct {
  items: std.ArrayList(i32),
  allocator: std.mem.Allocator,

  fn init(allocator: std.mem.Allocator) Bag {
      return .{ .items = .empty, .allocator = allocator };
  }

  fn deinit(self: *Bag) void {
      self.items.deinit(self.allocator);
  }

  fn add(self: *Bag, value: i32) !void {
      try self.items.append(self.allocator, value);
  }

  fn total(self: *const Bag) i32 {
      var sum: i32 = 0;
      for (self.items.items) |v| sum += v;
      return sum;
  }
};

pub fn main() !void {
  var gpa: std.heap.DebugAllocator(.{}) = .init;
  defer {
      const check = gpa.deinit();
      if (check == .leak) {
          std.debug.print("Leak detected!\n", .{});
      } else {
          std.debug.print("No leaks.\n", .{});
      }
  }

  var bag = Bag.init(gpa.allocator());

  try bag.add(10);
  try bag.add(20);
  try bag.add(30);

  std.debug.print("Count: {}\n", .{bag.items.items.len});
  std.debug.print("Total: {}\n", .{bag.total()});
}

Expected output:

Count: 3
Total: 60
No leaks.

Nothing calls bag.deinit(). The ArrayList asks the allocator for heap memory as it grows, Bag.deinit is the only thing that gives it back, and no one invokes it — so gpa.deinit() finds live allocations at exit and reports .leak. The fix is one line right after the Bag is created: defer bag.deinit();. Note that the printed numbers were all correct: a leak changes no output, which is exactly why you need an allocator that checks. The defer ordering matters too — defers run last-in-first-out, so a defer bag.deinit() written after the allocator's check is deferred still runs before it.

Assemble a leak-free test body yourself. The lines below are a whole test block, shuffled — and one arrangement is right for a reason that only shows up when an assertion fails.

Arrange the code

These lines form a test that builds a Bag, adds one value, reads it back, and asserts on it. The pieces are shuffled. Put them in the order that keeps the test leak-free even when an assertion fails — then answer the question the order is really testing: which line is the last one that can fail, what has to be registered before it, and which of these moves would the compiler NOT catch for you?

  1. defer bag.items.deinit(std.testing.allocator);
  2. try std.testing.expectEqual(@as(i32, 42), stored);
  3. try bag.add(std.testing.allocator, 42);
  4. const stored = bag.items.items[0];
  5. var bag: Bag = .{};
  6. test "bag holds what was added" {
  7. }

Error Testing

Test that functions return expected errors. expectError takes the error you expect and the call itself — not its result — because the call returns an error union that expectError has to inspect from the outside:

zig
test "parsePositiveInt errors" {
    try std.testing.expectError(ParseError.Empty, parsePositiveInt(""));
    try std.testing.expectError(ParseError.InvalidCharacter, parsePositiveInt("abc"));
    try std.testing.expectError(ParseError.Overflow, parsePositiveInt("99999999999"));
}

Note there is no try on the calls themselves — a try parsePositiveInt("") would propagate the error straight out of the test body and mark the test failed, which is the opposite of what you mean. Each assertion here fails in two ways: if the call succeeded, and if it failed with some error other than the one named.

The playground version drives the same function from main with if/else captures, since zig run will not execute a test block:

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

const ParseError = error{
    InvalidCharacter,
    Overflow,
    Empty,
};

fn parsePositiveInt(input: []const u8) ParseError!u32 {
    if (input.len == 0) return ParseError.Empty;

    var result: u64 = 0;
    for (input) |c| {
        if (c < '0' or c > '9') return ParseError.InvalidCharacter;
        result = result * 10 + (c - '0');
        if (result > std.math.maxInt(u32)) return ParseError.Overflow;
    }
    return @intCast(result);
}

pub fn main() !void {
    // Test successful parsing
    if (parsePositiveInt("123")) |val| {
        std.debug.print("Parsed '123': {}\n", .{val});
    } else |err| {
        std.debug.print("Unexpected error: {}\n", .{err});
    }

    // Test error cases
    if (parsePositiveInt("")) |val| {
        std.debug.print("Unexpected value: {}\n", .{val});
    } else |err| {
        std.debug.print("Empty string error: {}\n", .{err});
    }

    if (parsePositiveInt("12a3")) |val| {
        std.debug.print("Unexpected value: {}\n", .{val});
    } else |err| {
        std.debug.print("Invalid char error: {}\n", .{err});
    }

    if (parsePositiveInt("99999999999")) |val| {
        std.debug.print("Unexpected value: {}\n", .{val});
    } else |err| {
        std.debug.print("Overflow error: {}\n", .{err});
    }

    std.debug.print("\nAll error tests passed!\n", .{});
}

Notice what expectError asserts on: a call that fails. Close the code and work out why testing a failure needs a different assertion from testing a value.

Recall

Without scrolling up: in Error Handling you learned that try on a failing call propagates the error to your caller. Inside a test, why can't you write try parsePositiveInt("") to check that empty input fails — and what does expectError do instead?

try on a call you want to fail would propagate that error out of the test body — and a test that returns an error is a failed test, so you would be marking correct behavior as broken. expectError inverts it: give it the specific error you expect plus the call, and it succeeds only when that exact error came back, failing if the call returned a value or a different error. Naming the specific error matters — it means a function that starts failing for a new reason breaks the test instead of quietly still passing.

Rust's test story looks almost identical from a distance — an attribute, a runner, assertions in the same file as the code. Work out which part of it actually transfers before the build task.

Transfer

Rust marks a test function with the #[test] attribute, keeps it in the same file as the code it tests, and runs it with cargo test, which excludes those functions from a normal build. Zig writes a test block introduced by the test keyword and a name in quotes, keeps it in the same file, and runs it with zig test, which excludes it from a normal build. Which statement names what the two genuinely share, rather than a resemblance that stops at the syntax?

Try It Yourself

Reading about assertions is not the same as writing code that proves itself. This is a build task: a small program that reports its own pass/fail. A StringBuffer is given to you complete; three verification functions below it are stubbed out — one asserts a value, one asserts a specific error came back, and one asserts nothing leaked. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each one until every check passes and it prints All checks passed.

One constraint worth naming: the playground runs your file with zig run, and zig run does not execute test blocks — only zig test does. So this task asks you to write the assertions a test block would contain, driven from main with a check helper instead. The reasoning is identical; only the runner differs.

Build

Finish the build. Three verification functions are stubbed out and the checks below them fail until each one reports correctly. Run it as-is to see which check fails first, decide what that function must assert, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

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

const ParseError = error{ Empty, InvalidCharacter };

fn parseCount(input: []const u8) ParseError!u32 {
  if (input.len == 0) return ParseError.Empty;
  var result: u32 = 0;
  for (input) |c| {
      if (c < '0' or c > '9') return ParseError.InvalidCharacter;
      result = result * 10 + (c - '0');
  }
  return result;
}

const StringBuffer = struct {
  data: std.ArrayList(u8),
  allocator: std.mem.Allocator,

  fn init(allocator: std.mem.Allocator) StringBuffer {
      return .{ .data = .empty, .allocator = allocator };
  }

  fn deinit(self: *StringBuffer) void {
      self.data.deinit(self.allocator);
  }

  fn append(self: *StringBuffer, text: []const u8) !void {
      try self.data.appendSlice(self.allocator, text);
  }

  fn toString(self: *const StringBuffer) []const u8 {
      return self.data.items;
  }
};

// TODO 1: return true when parseCount(input) SUCCEEDS and returns expected.
//   Any error means false. Use an if/else capture on the error union:
//   if (parseCount(input)) |value| ... else |_| ...
//   parsedTo("42", 42) -> true,  parsedTo("42", 7) -> false,  parsedTo("", 0) -> false
fn parsedTo(input: []const u8, expected: u32) bool {
  _ = input;
  _ = expected;
  return false;
}

// TODO 2: return true when parseCount(input) FAILS with exactly the error
//   named by wanted. A success, or a different error, means false.
//   This is what std.testing.expectError does inside a real test block.
//   failedWith("", ParseError.Empty) -> true
//   failedWith("", ParseError.InvalidCharacter) -> false
//   failedWith("42", ParseError.Empty) -> false
fn failedWith(input: []const u8, wanted: ParseError) bool {
  _ = input;
  _ = &wanted; // an error value cannot be plain-discarded; discard a pointer to it
  return false;
}

// TODO 3: build a StringBuffer with the given allocator, append every part to
//   it, and return true when the joined text equals expected. Release the
//   buffer before returning on EVERY path — a defer is the way to guarantee it.
//   joinsTo(alloc, &.{ "a", "bc" }, "abc") -> true
fn joinsTo(allocator: std.mem.Allocator, parts: []const []const u8, expected: []const u8) !bool {
  _ = allocator;
  _ = parts;
  _ = expected;
  return false;
}

// A tiny stand-in for an assert: print what went wrong and return an error.
fn check(ok: bool, comptime message: []const u8) !void {
  if (!ok) {
      std.debug.print("CHECK FAILED: {s}\n", .{message});
      return error.CheckFailed;
  }
}

// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
  var gpa: std.heap.DebugAllocator(.{}) = .init;
  defer {
      const leaked = gpa.deinit();
      if (leaked == .leak) {
          std.debug.print("Leak detected!\n", .{});
      } else {
          std.debug.print("No leaks.\n", .{});
      }
  }

  try check(parsedTo("42", 42), "parsedTo(\"42\", 42) should be true");
  try check(!parsedTo("42", 7), "parsedTo(\"42\", 7) should be false — the value does not match");
  try check(!parsedTo("", 0), "parsedTo(\"\", 0) should be false — the call returns an error, not a value");

  try check(failedWith("", ParseError.Empty), "empty input should fail with Empty");
  try check(failedWith("12a", ParseError.InvalidCharacter), "'12a' should fail with InvalidCharacter");
  try check(!failedWith("", ParseError.InvalidCharacter), "empty input fails with Empty, not InvalidCharacter");
  try check(!failedWith("42", ParseError.Empty), "'42' succeeds, so it did not fail with any error");

  const alloc = gpa.allocator();
  try check(try joinsTo(alloc, &.{ "a", "bc" }, "abc"), "joinsTo should append both parts and match \"abc\"");
  try check(!try joinsTo(alloc, &.{ "a", "bc" }, "abd"), "joinsTo should be false when the joined text differs");

  std.debug.print("All checks passed.\n", .{});
  std.debug.print("parseCount(\"7\") == 7: {}\n", .{parsedTo("7", 7)});
}

Expected output:

All checks passed.
parseCount("7") == 7: true
No leaks.

Once it passes, try two variations and predict each before running:

  1. Accept any error instead of the named one. In failedWith, change the else branch from return err == wanted; to return true; (discarding err with _ = &err; — a plain _ = err; is rejected as "error set is discarded"). Predict which check fails before running. The two positive checks still pass, but empty input fails with Empty, not InvalidCharacter now fails, because the function reports success for an error it was never asked about. That is precisely the looseness expectError's named-error argument prevents.
  2. Remove the defer. In joinsTo, delete defer buf.deinit();. Predict whether a check fails or the output changes before running. Every check still passes — the joined text is unaffected — but the last line becomes Leak detected! instead of No leaks., because two StringBuffers were built and never released. A leak is invisible to your assertions; only the allocator sees it.

Capstone milestone

Milestone — the error pipeline. The capstone's Record Analyzer skips lines it cannot parse, counts the rejections, and reports them at the end — behavior that is only trustworthy if it is tested, including the failure paths. The verification helpers you just wrote are those tests in miniature: one for the success value, one for the specific error, one for leak-free cleanup. Confirm you can assert on both halves of an error union.

Hint: You don't need the full pipeline yet — this confirms the assert-both-branches habit it depends on. In the capstone, loadRecords uses if (parseRecord(line)) |record| … else |err| … to keep going past a bad line, and its tests have to cover both branches plus the errdefer that releases a partially built list.

  • Asserted on a successful return value by capturing the value side of an error union
  • Asserted that a call failed with one specific error, not merely that it failed
  • Paired every allocation with a defer release so the error path cleans up too
  • Confirmed a leak-checking allocator reports clean at exit, with no assertion able to catch a leak on its own

Key Takeaways

  • Test blocks (test "name" { ... }) live alongside source code and run with zig test
  • std.testing.expectEqual, expect, expectError are the core assertion functions
  • std.testing.allocator detects memory leaks — if you forget deinit, the test fails
  • Test error cases with expectError to verify functions fail correctly
  • Tests are compiled and run separately — they don't affect production builds
  • Use defer in tests for cleanup, just like in regular code

Pro Tip: Always use std.testing.allocator in tests instead of a general-purpose allocator. It's specifically designed to catch memory leaks and will fail your test if any allocation isn't freed. This catches bugs that would be invisible with a regular allocator.

Next Steps

Now that you can write tests, it's time to explore one of Zig's most distinctive features: compile-time code execution, which lets you generate types, build lookup tables, and unroll loops with zero runtime overhead.

Next lesson

Zig Comptime — Compile-Time Programming Guide

Zig comptime tutorial — learn compile-time evaluation, comptime parameters, type reflection, and how to write generic zero-cost abstractions. Free tutorial with runnable examples.

25 min