Learn Zig Series (#12) - Testing and Test-Driven Development

avatar
(Edited)

Learn Zig Series (#12) - Testing and Test-Driven Development

zig-banner.png

What will I learn

  • You will learn how Zig's built-in test blocks work and why they're a first-class language feature;
  • writing unit tests alongside your code without a separate test framework;
  • using std.testing.expect, std.testing.expectEqual, and other assertion functions;
  • testing error unions and verifying that functions return expected errors;
  • testing with allocators: the std.testing.allocator that detects memory leaks automatically;
  • organizing tests across multiple files and running specific tests by name;
  • the TDD workflow: write the test first, watch it fail, make it pass;
  • table-driven tests, comptime tests, and patterns for real-world test code;
  • how zig test integrates with the build system from episode 10.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#12) - Testing and Test-Driven Development

Welcome back! In episode #11 we combined everything from episodes 1-10 into a real multi-file program -- a terminal step sequencer with an engine, display, storage, and coordinator, all split into separate modules. We used structs, enums, arrays, error handling, file I/O, allocators, pointers, comptime defaults, and modules. Ten episodes of foundations, one episode of putting them together.

And if you actually built that sequencer and tried modifying it (adding a fifth track, implementing swing timing, playing with the save/load format), you probably noticed something. You made a change. You ran the program. You manually stepped through the grid pressing keys. You checked if the output looked right. You made another change. You ran it again. Manual testing. Every single time.

That's fine for a mini project. It is NOT fine for anything larger. Imagine having 20 source files instead of 4, with functions that call functions that call other functions three layers deep. Manually clicking through the program to verify that your change to storage.zig didn't break sequencer.zig -- that way lies madness.

Good news -- Zig has testing built directly into the language. No external framework to install, no test runner to configure, no special directory conventions. Just write test "name" { ... } right next to the code it tests, and run zig test. That's it. The compiler handles everything.

Here we go!

Solutions to Episode 11 Exercises

Before we start on testing, here are the solutions to last episode's exercises. As always, if you actually typed these out and compiled them -- compare your approaches:

Exercise 1 -- adding a fifth track:

// In sequencer.zig:
const NUM_TRACKS = 5; // was 4

const Sound = enum {
    kick,
    snare,
    hihat,
    clap,
    cowbell, // new

    fn label(self: Sound) []const u8 {
        return switch (self) {
            .kick => "KICK ",
            .snare => "SNARE",
            .hihat => "HIHAT",
            .clap => "CLAP ",
            .cowbell => "COWBL",
        };
    }

    fn symbol(self: Sound) u8 {
        return switch (self) {
            .kick => 'K',
            .snare => 'S',
            .hihat => 'H',
            .clap => 'C',
            .cowbell => 'B',
        };
    }
};

const tracks: [NUM_TRACKS]Sound = .{ .kick, .snare, .hihat, .clap, .cowbell };

Because the grid dimensions are based on NUM_TRACKS, everything adjusts automatically -- display, save/load, cursor range. If you forgot to handle .cowbell in a switch, the compiler tells you exactly which case is missing. That's exhaustive switching doing its job.

Exercise 2 -- swing timing:

// Add to Sequencer struct:
swing: f64 = 0.0,

fn stepDelayMs(self: *const Sequencer) u64 {
    const base = @as(u64, 60_000) / @as(u64, self.bpm) / 4;
    const base_f: f64 = @floatFromInt(base);
    if (self.position % 2 == 0) {
        return @intFromFloat(base_f * (1.0 + self.swing));
    } else {
        return @intFromFloat(base_f * (1.0 - self.swing));
    }
}

// In handleInput:
'[' => { self.swing = @max(0.0, self.swing - 0.05); },
']' => { self.swing = @min(0.5, self.swing + 0.05); },

At swing = 0.0 every step is equal. At swing = 0.5 even steps are 1.5x and odd steps 0.5x -- a heavy shuffle. The explicit casts between int and float are Zig being Zig -- no silent conversions.

Exercise 3 -- ASCII export:

// In storage.zig:
pub fn exportAscii(seq: *const Sequencer, path: []const u8) !void {
    const file = try std.fs.cwd().createFile(path, .{});
    defer file.close();
    const writer = file.writer();

    for (0..NUM_TRACKS) |t| {
        try writer.print("{s}  ", .{seq.tracks[t].label()});
        for (0..NUM_STEPS) |s| {
            try writer.writeByte(if (seq.grid[t][s]) 'X' else '.');
        }
        try writer.writeByte('\n');
    }
}

Output looks like KICK X...X..X....X... -- pipe to > pattern.txt for a shareable diagram. No metadata, no headers, just the visual pattern.

Exercise 4 -- undo:

// Add to Sequencer struct:
prev_grid: [NUM_TRACKS][NUM_STEPS]bool = [_][NUM_STEPS]bool{[_]bool{false} ** NUM_STEPS} ** NUM_TRACKS,

// In toggle method, before flipping:
self.prev_grid = self.grid; // value copy -- entire grid
self.grid[track][step] = !self.grid[track][step];

// Key binding:
'u' => {
    const tmp = self.grid;
    self.grid = self.prev_grid;
    self.prev_grid = tmp;
},

One level of undo in four lines. Value semantics from ep006 make the array copy trivial -- self.prev_grid = self.grid copies every bool in the 2D array. No deep clone, no pointer management, no surprise aliasing.

Exercise 5 -- mute per track:

// Add to Sequencer struct:
muted: [NUM_TRACKS]bool = [_]bool{false} ** NUM_TRACKS,

// In display.zig -- dim muted tracks:
const color = if (seq.muted[t]) "\x1b[90m" else "\x1b[32m";
try writer.print("{s}{s}\x1b[0m", .{ color, cell_text });

// In the "Now:" line -- skip muted tracks:
for (0..NUM_TRACKS) |t| {
    if (seq.muted[t]) continue;
    if (seq.grid[t][seq.position]) {
        try writer.print("{c} ", .{seq.tracks[t].symbol()});
    }
}

// Key binding:
'm' => { self.muted[self.cursor_track] = !self.muted[self.cursor_track]; },

Muted tracks still show their pattern but in dim gray (\x1b[90m). They're visually present but silenced -- the sound engine skips them.

Exercise 6 -- pattern copy:

// Add a clipboard -- same type as Sequencer:
var clipboard: Sequencer = .{};

// Key bindings:
'y' => { clipboard = self.*; }, // copy: dereference + value copy
'p' => { self.grid = clipboard.grid; }, // paste: only the grid

clipboard = self.* copies ALL fields by value -- the dereference .* gives you the struct itself (not a pointer), and the assignment copies every field. For paste, we only copy the grid (not position, bpm, etc.). This is value semantics in action -- Sequencer is 100+ bytes of stack data, and Zig copies it all with one =. No memcpy needed, no clone method, no move semantics. Just assignment.

Now -- testing!

Test Blocks Are Part of the Language

In most languages, testing is an afterthought. Python needs pytest or unittest. JavaScript needs jest or mocha. C needs... well, pick your adventure from a dozen frameworks, each with its own assertion macros, its own test runner, its own configuration file. Rust has a built-in test convention with #[cfg(test)] modules, which is closer to what Zig does -- but Zig goes further.

In Zig, a test block is a language construct, same as fn, struct, or enum. It lives right next to the code it tests:

const std = @import("std");

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

fn multiply(a: i32, b: i32) i32 {
    return a * b;
}

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

test "add with negative" {
    try std.testing.expectEqual(@as(i32, -1), add(2, -3));
}

test "multiply basics" {
    try std.testing.expectEqual(@as(i32, 12), multiply(3, 4));
    try std.testing.expectEqual(@as(i32, 0), multiply(0, 999));
}

Save this as math.zig and run zig test math.zig. You'll see output like:

All 3 tests passed.

Three things to notice immediately:

Test blocks are stripped from release builds. When you compile with zig build or zig build -Doptimize=ReleaseFast, the test blocks don't exist in the binary. Zero overhead. Zero bloat. They're only compiled and executed when you explicitly run zig test. This means there is absolutely no cost to putting tests right next to the functions they test, in the same file. Do it. Always.

The try before each assertion. Every expect* function returns an error union -- if the assertion fails, it returns error.TestExpectedEqual (or similar). The try propagates that error to the test runner, which marks the test as failed and shows you what went wrong. This is the same error handling pattern from episode #4 -- tests are just functions that return errors. No special assertion magic. No macros. No exceptions.

The @as(i32, 7) cast. This tells the compiler that the expected value is an i32, matching the return type of add. Without it, 7 is a comptime_int and the comparison wouldn't type-check. A small annoyance, but it forces you to be explicit about what type you're testing -- which is a good habit anyway.

The std.testing Namespace

Zig's standard library provides quite some assertion functions in std.testing. Here's the full set you'll actually use:

const std = @import("std");
const testing = std.testing;

fn computeAnswer() u32 {
    return 42;
}

fn computePi() f64 {
    return 3.14159;
}

fn greet() []const u8 {
    return "hello";
}

fn getBytes() []const u8 {
    return &[_]u8{ 1, 2, 3 };
}

test "exact equality" {
    try testing.expectEqual(@as(u32, 42), computeAnswer());
}

test "boolean expectation" {
    const value = computeAnswer();
    try testing.expect(value > 0);
    try testing.expect(value < 100);
}

test "approximate equality for floats" {
    try testing.expectApproxEqAbs(@as(f64, 3.14), computePi(), 0.01);
}

test "string equality" {
    try testing.expectEqualStrings("hello", greet());
}

test "slice equality" {
    try testing.expectEqualSlices(u8, &[_]u8{ 1, 2, 3 }, getBytes());
}

expectEqual checks exact equality. Works for any type that supports ==. This is your workhorse.

expect checks that a boolean expression is true. Use it for comparisons, range checks, or any condition that isn't a simple equality.

expectApproxEqAbs checks that two floats are within an absolute tolerance. NEVER use expectEqual on floats -- floating-point arithmetic means 0.1 + 0.2 != 0.3 (remember this from ep002? same problem in every language). The third argument is the maximum allowed difference.

expectEqualStrings and expectEqualSlices compare byte sequences. When they fail, they produce a diff showing exactly which bytes differ -- much more useful than a generic "not equal" message.

Testing Error Returns

Since Zig uses error unions extensively (we covered this in depth in episode #4), testing error cases is natural and important. You want to verify that functions reject bad input correctly, not just that they handle good input:

const std = @import("std");
const testing = std.testing;

const MathError = error{
    DivisionByZero,
    Overflow,
};

fn divide(a: f64, b: f64) MathError!f64 {
    if (b == 0) return error.DivisionByZero;
    return a / b;
}

fn safePow(base: u32, exp: u32) MathError!u32 {
    var result: u32 = 1;
    for (0..exp) |_| {
        const next = @mulWithOverflow(result, base);
        if (next[1] != 0) return error.Overflow;
        result = next[0];
    }
    return result;
}

test "valid division" {
    const result = try divide(10.0, 2.0);
    try testing.expectApproxEqAbs(@as(f64, 5.0), result, 0.001);
}

test "division by zero returns error" {
    const result = divide(10.0, 0.0);
    try testing.expectError(error.DivisionByZero, result);
}

test "power overflow returns error" {
    const result = safePow(2, 33);
    try testing.expectError(error.Overflow, result);
}

test "power normal case" {
    const result = try safePow(2, 10);
    try testing.expectEqual(@as(u32, 1024), result);
}

expectError verifies that an error union contains a specific error. In most other languages, testing error cases requires awkward try/catch wrappers around assertions. In Zig it's one line. The error handling system and the testing system compose naturally because they use the same mechanism -- error unions all the way down.

Notice the pattern: test the happy path with try (which will fail the test if an unexpected error occurs) and test the error path with expectError. Cover both sides. If you only test happy paths, you're only testing half your code.

The Testing Allocator -- Memory Leak Detection For Free

Here's where Zig's testing story gets genuinely impressive. Remember from episode #7 how allocators are explicit -- you pass them around, you choose which one to use, and you're responsible for freeing what you allocate? The testing allocator takes this a step further by tracking every allocation and reporting leaks when the test ends:

const std = @import("std");
const testing = std.testing;

fn processData(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
    var result = try allocator.alloc(u8, input.len);
    for (input, 0..) |byte, i| {
        result[i] = if (byte >= 'a' and byte <= 'z') byte - 32 else byte;
    }
    return result;
}

test "processData converts to uppercase" {
    const allocator = testing.allocator;

    const result = try processData(allocator, "hello world");
    defer allocator.free(result);

    try testing.expectEqualStrings("HELLO WORLD", result);
}

test "processData with empty input" {
    const allocator = testing.allocator;

    const result = try processData(allocator, "");
    defer allocator.free(result);

    try testing.expectEqual(@as(usize, 0), result.len);
}

If you forget that defer allocator.free(result) line, the test fails with a leak report telling you exactly how many bytes leaked and where they were allocated. Try it -- comment out the defer line and run zig test. The testing allocator catches the leak immediately.

This is a killer feature. In C, you discover memory leaks with valgrind after the fact (and only if you remember to run it). In Python, memory leaks are rarely caught because the garbage collector hides them (until your server runs out of RAM at 3 AM). In Zig, the testing allocator turns every test into a leak detector. If your function takes an allocator parameter (and it should, per the patterns from ep007), the testing allocator will find every missed free.

Here's a more realistic example -- testing an ArrayList:

test "dynamic list operations" {
    const allocator = testing.allocator;

    var list = std.ArrayList(u8).init(allocator);
    defer list.deinit();

    try list.append(10);
    try list.append(20);
    try list.append(30);

    try testing.expectEqual(@as(usize, 3), list.items.len);
    try testing.expectEqual(@as(u8, 20), list.items[1]);

    _ = list.orderedRemove(1);
    try testing.expectEqual(@as(usize, 2), list.items.len);
    try testing.expectEqual(@as(u8, 30), list.items[1]);
}

The defer list.deinit() frees the internal buffer. If you forget it, the testing allocator will tell you. Every test that touches heap memory should use std.testing.allocator -- make it a habit.

TDD: Test-Driven Development

The TDD cycle is simple:

  1. Write a test for behavior that doesn't exist yet
  2. Run zig test -- watch it fail (compile error or assertion failure)
  3. Write the minimum code to make it pass
  4. Refactor if needed
  5. Repeat

The test drives the design. You decide what the API should look like before you implement it. Let's build a Stack data structure using TDD:

const std = @import("std");
const testing = std.testing;

// Step 1: write the tests first -- Stack doesn't exist yet

test "stack push and pop" {
    var stack = Stack(i32).init(testing.allocator);
    defer stack.deinit();

    try stack.push(10);
    try stack.push(20);
    try stack.push(30);

    try testing.expectEqual(@as(i32, 30), try stack.pop());
    try testing.expectEqual(@as(i32, 20), try stack.pop());
    try testing.expectEqual(@as(i32, 10), try stack.pop());
}

test "stack pop on empty returns error" {
    var stack = Stack(i32).init(testing.allocator);
    defer stack.deinit();

    const result = stack.pop();
    try testing.expectError(error.StackEmpty, result);
}

test "stack peek doesn't remove" {
    var stack = Stack(i32).init(testing.allocator);
    defer stack.deinit();

    try stack.push(42);
    try testing.expectEqual(@as(i32, 42), try stack.peek());
    try testing.expectEqual(@as(i32, 42), try stack.pop());
}

test "stack size tracks correctly" {
    var stack = Stack(i32).init(testing.allocator);
    defer stack.deinit();

    try testing.expectEqual(@as(usize, 0), stack.size());
    try stack.push(1);
    try testing.expectEqual(@as(usize, 1), stack.size());
    try stack.push(2);
    try testing.expectEqual(@as(usize, 2), stack.size());
    _ = try stack.pop();
    try testing.expectEqual(@as(usize, 1), stack.size());
}

Running zig test now gives a compile error -- Stack doesn't exist. That's expected. Step 2 done. Now step 3 -- implement just enough to make the tests pass:

fn Stack(comptime T: type) type {
    return struct {
        items: std.ArrayList(T),

        const Self = @This();

        pub fn init(allocator: std.mem.Allocator) Self {
            return .{ .items = std.ArrayList(T).init(allocator) };
        }

        pub fn deinit(self: *Self) void {
            self.items.deinit();
        }

        pub fn push(self: *Self, value: T) !void {
            try self.items.append(value);
        }

        pub fn pop(self: *Self) !T {
            if (self.items.items.len == 0) return error.StackEmpty;
            return self.items.pop();
        }

        pub fn peek(self: Self) !T {
            if (self.items.items.len == 0) return error.StackEmpty;
            return self.items.items[self.items.items.len - 1];
        }

        pub fn size(self: Self) usize {
            return self.items.items.len;
        }
    };
}

Run zig test again -- all four tests pass. The tests drove the entire API design. We decided that Stack should be generic (from writing Stack(i32)), that it should take an allocator (from writing .init(testing.allocator)), that pop and peek should return errors on empty (from writing expectError), and that size should exist (from writing stack.size()). All those decisions came from writing the tests first.

If you're coming from a Python background (and I suspect a few of you are, given the LPS series ;-) ), think of TDD in Zig as even more valuable than in Python. In Python, a missing method gives you a NameError at runtime -- maybe. If that code path isn't hit, you don't know it's broken. In Zig, a missing method gives you a compile error immediately. The type system catches a whole class of bugs that Python tests exist to catch. TDD in Zig lets you focus on behavioral correctness -- does the function do the right thing? -- rather than structural correctness that the compiler already handles.

Running Tests Across Files

When your project has multiple files (like the step sequencer from ep011), zig test can discover tests across the entire import graph:

# Test a single file
zig test src/stack.zig

# Test via the build system
zig build test

For zig build test to work, you need a test step in your build.zig (remember the build script from ep010?):

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

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

Now zig build test compiles and runs every test block reachable from main.zig through its @import chain. If main.zig imports sequencer.zig, and sequencer.zig has test blocks -- those tests run. If sequencer.zig imports types.zig and types.zig has tests -- those run too. The import graph determines which tests are in scope, automatically.

Filtering Tests

When you have dozens or hundreds of tests and want to focus on one:

zig test src/stack.zig --test-filter "pop on empty"

This runs only tests whose names contain "pop on empty". Useful when you're debugging a specific failure and don't want to wait for every other test to pass first.

Test Patterns for Real Code

Setup and Teardown with defer

Most test frameworks have beforeEach / afterEach hooks, setup fixtures, teardown methods. Zig doesn't need any of that -- defer is your teardown:

test "database-like operations" {
    const allocator = testing.allocator;

    // Setup
    var store = std.StringHashMap([]const u8).init(allocator);
    defer store.deinit();  // Teardown -- runs no matter what

    // Test
    try store.put("name", "scipio");
    try store.put("lang", "zig");

    try testing.expectEqualStrings("scipio", store.get("name").?);
    try testing.expectEqualStrings("zig", store.get("lang").?);
    try testing.expect(store.get("missing") == null);
}

defer runs when the test block exits, whether it passes or fails. Same pattern you use in production code -- same pattern from ep004. No new concept to learn. No framework-specific lifecycle hooks. Just defer.

Table-Driven Tests

When you want to test many inputs against the same function, use a comptime array of test cases:

test "fizzbuzz" {
    const cases = [_]struct { input: u32, expected: []const u8 }{
        .{ .input = 1, .expected = "1" },
        .{ .input = 2, .expected = "2" },
        .{ .input = 3, .expected = "Fizz" },
        .{ .input = 5, .expected = "Buzz" },
        .{ .input = 15, .expected = "FizzBuzz" },
        .{ .input = 30, .expected = "FizzBuzz" },
        .{ .input = 7, .expected = "7" },
    };

    var buf: [16]u8 = undefined;
    for (cases) |case| {
        const result = fizzbuzz(case.input, &buf);
        try testing.expectEqualStrings(case.expected, result);
    }
}

fn fizzbuzz(n: u32, buf: *[16]u8) []const u8 {
    if (n % 15 == 0) return "FizzBuzz";
    if (n % 3 == 0) return "Fizz";
    if (n % 5 == 0) return "Buzz";
    const len = std.fmt.bufPrint(buf, "{d}", .{n}) catch return "?";
    return len;
}

No framework macros needed, no parameterized test decorators. Just an array of structs and a for loop. The type system (from ep006 -- anonymous struct literals) handles the rest. If you need to add a test case, you add one line to the array.

Testing Comptime Code

Since comptime code runs at compile time (we explored this extensively in ep009), you can test it with compile-time assertions:

fn myStrLen(comptime s: []const u8) comptime_int {
    return s.len;
}

fn SafeArray(comptime size: usize) type {
    if (size == 0) @compileError("SafeArray size must be > 0");
    return struct {
        data: [size]u8 = [_]u8{0} ** size,
    };
}

test "comptime string length" {
    comptime {
        const len = myStrLen("hello");
        if (len != 5) @compileError("wrong length");
    }
    // If we get here, comptime assertion passed
    try testing.expectEqual(@as(comptime_int, 5), comptime myStrLen("hello"));
}

test "SafeArray rejects zero size" {
    // We can't test @compileError directly (it would stop compilation!)
    // But we CAN verify that valid sizes work:
    const Arr = SafeArray(10);
    const a = Arr{};
    try testing.expectEqual(@as(usize, 10), a.data.len);
}

Comptime errors stop compilation entirely -- so you can't really "catch" a @compileError in a test and assert that it fired. But you CAN test that comptime functions return the right values by calling them in a comptime block or by using comptime as a keyword before the call. And you can test that valid inputs don't trigger compile errors, which is almost as useful.

Testing Structs with Methods

When testing struct methods, the pattern mirrors production code closely:

const Counter = struct {
    value: i32 = 0,
    min: i32 = 0,
    max: i32 = 100,

    pub fn increment(self: *Counter) void {
        if (self.value < self.max) self.value += 1;
    }

    pub fn decrement(self: *Counter) void {
        if (self.value > self.min) self.value -= 1;
    }

    pub fn reset(self: *Counter) void {
        self.value = self.min;
    }
};

test "counter starts at zero" {
    const c = Counter{};
    try testing.expectEqual(@as(i32, 0), c.value);
}

test "counter increments" {
    var c = Counter{};
    c.increment();
    c.increment();
    c.increment();
    try testing.expectEqual(@as(i32, 3), c.value);
}

test "counter respects maximum" {
    var c = Counter{ .max = 2 };
    c.increment();
    c.increment();
    c.increment(); // should NOT go above 2
    try testing.expectEqual(@as(i32, 2), c.value);
}

test "counter respects minimum" {
    var c = Counter{ .min = -5 };
    c.decrement(); // value = 0, min = -5, so this should go to -1
    // Wait -- value starts at 0 and min is -5, so decrement should work
    try testing.expectEqual(@as(i32, -1), c.value);
}

test "counter reset goes to min" {
    var c = Counter{ .min = 10, .value = 50 };
    c.reset();
    try testing.expectEqual(@as(i32, 10), c.value);
}

Notice how the tests document the behavior. Someone reading these tests knows exactly what Counter does, what its edge cases are, and what the expected behavior is at the boundaries. Tests are documentation that the compiler verifies.

Putting It Together: Testing the Step Sequencer

Let me show you how you'd add tests to the sequencer from ep011. In src/sequencer.zig, right after the Sequencer struct:

test "toggle flips cell state" {
    var seq = Sequencer{};
    try testing.expect(!seq.grid[0][0]);

    seq.toggle(0, 0);
    try testing.expect(seq.grid[0][0]);

    seq.toggle(0, 0);
    try testing.expect(!seq.grid[0][0]);
}

test "toggle ignores out of bounds" {
    var seq = Sequencer{};
    seq.toggle(99, 99); // should not crash
    seq.toggle(0, NUM_STEPS + 5); // should not crash
}

test "advance wraps around" {
    var seq = Sequencer{};
    for (0..NUM_STEPS) |_| {
        seq.advance();
    }
    try testing.expectEqual(@as(u32, 0), seq.position);
}

test "loadPreset sets known pattern" {
    var seq = Sequencer{};
    seq.loadPreset();

    // Four-on-the-floor kick
    try testing.expect(seq.grid[0][0]);
    try testing.expect(seq.grid[0][4]);
    try testing.expect(seq.grid[0][8]);
    try testing.expect(seq.grid[0][12]);
    try testing.expect(!seq.grid[0][1]);

    // Snare on backbeat
    try testing.expect(seq.grid[1][4]);
    try testing.expect(seq.grid[1][12]);
    try testing.expect(!seq.grid[1][0]);
}

test "clear resets everything" {
    var seq = Sequencer{};
    seq.loadPreset();
    seq.position = 5;
    seq.clear();

    try testing.expectEqual(@as(u32, 0), seq.position);
    for (0..NUM_TRACKS) |t| {
        for (0..NUM_STEPS) |s| {
            try testing.expect(!seq.grid[t][s]);
        }
    }
}

test "stepDelayMs computes correctly" {
    var seq = Sequencer{ .bpm = 120 };
    // 120 BPM = 500ms per beat = 125ms per 16th note
    try testing.expectEqual(@as(u64, 125), seq.stepDelayMs());

    seq.bpm = 60;
    // 60 BPM = 1000ms per beat = 250ms per 16th note
    try testing.expectEqual(@as(u64, 250), seq.stepDelayMs());
}

Every test verifies a specific behavior. Toggle flips on, then off. Out-of-bounds toggle doesn't crash. Advance wraps at NUM_STEPS. The preset loads a known pattern. Clear resets everything. These tests live right in sequencer.zig, next to the code they test. Zero separation between implementation and verification.

Run zig test src/sequencer.zig and they all pass. Now if you modify toggle, advance, loadPreset, or clear in the future, these tests will catch any regressions immediately. No manual clicking through the terminal UI. Change the code, run the tests, done.

What Testing Reveals About Architecture

One thing that surprised me when I first started writing tests for Zig code: the code that's easy to test is the same code that's well-architectured. And the code that's hard to test usually has a design problem.

The Sequencer struct is easy to test because it's pure data + logic. No file I/O, no terminal output, no keyboard input. You create one, call methods, check state. The display.zig module is harder to test because it writes to stderr -- you'd need to capture output to verify it. The main.zig module with its raw terminal mode and keyboard reading loop is the hardest to test because it's tightly coupled to the terminal.

This is the same seperation of concerns from ep011, now seen through the lens of testability. The engine (pure logic) is trivially testable. The display (output) is testable but requires some setup. The coordinator (I/O + input) is hard to test in isolation. Good architecture and good testability are the same thing.

If you find a function hard to test, consider: can you split it into a pure computation part (easy to test) and an I/O part (harder to test but smaller)? Almost always, yes. And that split makes the code better even apart from testing.

Exercises

  1. Write a RingBuffer(comptime T: type, comptime capacity: usize) using TDD. Start with the tests: push adds elements, pop returns in FIFO order, the buffer wraps around when full (overwriting oldest elements), isEmpty and isFull work correctly. Then implement the struct to make all tests pass. Use the testing allocator to verify zero heap usage (hint: a ring buffer shouldn't need the heap at all -- fixed-size array on the stack).

  2. Add tests to the Portfolio from ep010's multi-file example. Test addHolding with valid data, addHolding when the portfolio is full (should return error.PortfolioFull), totalValue with zero holdings and with multiple holdings, and largestHolding with one holding and with several.

  3. Write a parseKeyValue function that takes a string like "name=scipio" and returns a struct { key: []const u8, value: []const u8 }. Test it with table-driven tests covering: normal input, empty value ("key="), no equals sign (should return an error), multiple equals signs ("a=b=c" -- value should be "b=c"), and empty input.

  4. Write a test that intentionally leaks memory to see what the testing allocator reports. Allocate a []u8 buffer with testing.allocator, do NOT free it, and observe the test output. Then fix it. This is worth seeing once so you recognize the error message in the future.

  5. Using TDD, implement a StatTracker that accepts f64 values and computes running min, max, mean, and count. Write all four tests first, then implement the struct. The mean should use the incrementel formula mean = mean + (value - mean) / count to avoid storing all values.

  6. Add test coverage to the storage.zig save and load functions from ep011. Create a Sequencer, call save to write it to a temporary file, then load a fresh Sequencer from that file and verify the grid matches. Use the testing allocator for load. Delete the temp file with std.fs.cwd().deleteFile("test_pattern.seq") catch {}; in a defer.

Wat we geleerd hebben

  • test "name" { ... } blocks are first-class Zig -- no external framework, no configuration, no test runner to install
  • std.testing.expect* functions cover equality, errors, strings, floats, and slices -- all returning error unions, composing with the same try patterns from ep004
  • The testing allocator catches memory leaks at test time -- every missed free becomes a test failure, not a production incident at 3 AM
  • TDD in Zig feels natural because the type system handles structural correctness and tests focus on behavioral correctness
  • defer replaces setup/teardown -- same cleanup pattern you use in production code, no framework-specific lifecycle hooks
  • Table-driven tests use arrays of anonymous structs and a loop -- no macros, no parameterize decorators
  • zig build test runs all tests reachable through the import graph from the root source file
  • Test blocks are stripped from release builds -- zero runtime cost, so put them next to the code they test, always

Next time we're getting into something that changed how I think about polymorphism in systems languages -- how Zig achieves interface-like behavior through type erasure, without inheritance, without virtual method tables (well... almost without vtables), and without the runtime overhead you'd expect. If you've been wondering how to write functions that accept "anything with a write method" -- that's the episode ;-)

Thanks for reading, tot de volgende keer!

@scipio



0
0
0.000
1 comments
avatar

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

0
0
0.000