Skip to content

Writing Unit Tests

A unit test checks one small piece of code in isolation.

A unit test checks one small piece of code in isolation.

Usually, that “unit” is a single function.

For example:

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

A unit test for this function checks whether it behaves correctly:

const std = @import("std");

test "multiply returns the product of two numbers" {
    try std.testing.expectEqual(@as(i32, 12), multiply(3, 4));
}

The goal is simple:

give the function input

check the output

If the output is wrong, the test fails.

Why Unit Tests Matter

When programs grow larger, changing code becomes risky.

You might improve one function and accidentally break another part of the program.

Unit tests reduce that risk.

A good unit test gives you confidence that:

the function still works

edge cases still behave correctly

refactoring did not break behavior

future bugs are easier to detect

Tests also improve design. A function that is difficult to test is often difficult to understand.

A Unit Test Should Be Small

A unit test should focus on one behavior.

Bad example:

test "everything works" {
    // too many unrelated checks
}

Good example:

test "parseNumber handles valid input" {
    // one clear behavior
}

test "parseNumber rejects invalid input" {
    // another clear behavior
}

Small tests are easier to read and easier to debug.

When a test fails, you want to know exactly what broke.

The Basic Structure of a Unit Test

Most unit tests follow the same pattern:

  1. create input
  2. call the function
  3. check the result

Example:

const std = @import("std");

fn subtract(a: i32, b: i32) i32 {
    return a - b;
}

test "subtract removes the second number from the first" {
    const result = subtract(10, 3);

    try std.testing.expectEqual(@as(i32, 7), result);
}

This style is straightforward:

setup

action

verification

Keep tests mechanically simple.

Testing Multiple Cases

Most functions should be tested with multiple inputs.

Example:

const std = @import("std");

fn isPositive(n: i32) bool {
    return n > 0;
}

test "isPositive handles positive numbers" {
    try std.testing.expect(isPositive(10));
}

test "isPositive handles zero" {
    try std.testing.expect(!isPositive(0));
}

test "isPositive handles negative numbers" {
    try std.testing.expect(!isPositive(-5));
}

Each test checks one category of input.

This matters because bugs often appear only in special cases.

Test Normal Cases First

Beginners sometimes start with strange edge cases immediately.

Start with normal input first.

Suppose you write this function:

fn average(a: i32, b: i32) i32 {
    return (a + b) / 2;
}

First test:

test "average calculates the midpoint between two numbers" {
    try std.testing.expectEqual(@as(i32, 15), average(10, 20));
}

After normal behavior works, test boundaries and unusual input.

Then Test Edge Cases

Edge cases are values near limits or boundaries.

Example:

test "average handles equal numbers" {
    try std.testing.expectEqual(@as(i32, 5), average(5, 5));
}

test "average handles zero" {
    try std.testing.expectEqual(@as(i32, 0), average(0, 0));
}

test "average handles negative numbers" {
    try std.testing.expectEqual(@as(i32, -15), average(-10, -20));
}

Good unit tests combine:

normal cases

boundary cases

invalid cases

special cases

Unit Tests Should Be Independent

One test should not depend on another test.

Bad idea:

var global_counter: i32 = 0;

test "increments counter" {
    global_counter += 1;
}

test "expects counter to already be incremented" {
    try std.testing.expectEqual(@as(i32, 1), global_counter);
}

This is fragile because test order should not matter.

A good test creates its own state:

test "counter starts at zero" {
    var counter: i32 = 0;
    counter += 1;

    try std.testing.expectEqual(@as(i32, 1), counter);
}

Independent tests are predictable.

Avoid Large Test Functions

A unit test should stay small.

Bad:

test "big complicated test" {
    // 200 lines
}

Large tests become difficult to understand.

Instead, split behavior into separate tests:

test "parser handles empty input" {}

test "parser handles whitespace" {}

test "parser handles valid numbers" {}

test "parser rejects invalid characters" {}

This produces better failure messages and cleaner structure.

Use Clear Test Names

Test names should explain behavior.

Weak:

test "math test" {}

Better:

test "divide returns the quotient for nonzero divisors" {}

A test name should answer:

“What behavior is this checking?”

You should understand the purpose without reading the implementation.

Testing Strings

Strings in Zig are slices of bytes:

[]const u8

Use expectEqualStrings for string comparisons.

Example:

const std = @import("std");

fn language() []const u8 {
    return "Zig";
}

test "language returns Zig" {
    try std.testing.expectEqualStrings("Zig", language());
}

This produces better diagnostics if the strings differ.

Testing Floating Point Numbers

Floating point numbers are not always exact.

This can surprise beginners.

Example:

const x = 0.1 + 0.2;

You might expect exactly 0.3, but binary floating point representation introduces tiny rounding differences.

So avoid direct equality checks like this:

try std.testing.expect(x == 0.3);

Instead, compare using tolerance:

const std = @import("std");

test "floating point addition" {
    const result = 0.1 + 0.2;

    try std.testing.expectApproxEqAbs(
        @as(f64, 0.3),
        result,
        0.000001,
    );
}

This means:

“The result should be very close to 0.3.”

Testing Errors Carefully

Suppose a function can fail:

const ParseError = error{
    InvalidInput,
};

fn parseDigit(c: u8) !u8 {
    if (c < '0' or c > '9') {
        return ParseError.InvalidInput;
    }

    return c - '0';
}

Test both success and failure.

Success:

test "parseDigit converts numeric characters" {
    try std.testing.expectEqual(
        @as(u8, 5),
        try parseDigit('5'),
    );
}

Failure:

test "parseDigit rejects non-numeric characters" {
    try std.testing.expectError(
        ParseError.InvalidInput,
        parseDigit('x'),
    );
}

Good tests check both sides of the API contract.

Unit Tests Should Avoid Real External Systems

A unit test should avoid:

real network requests

real databases

real web APIs

real filesystem dependencies when possible

These make tests slower and less predictable.

Bad:

test "downloads data from the internet" {
    // unreliable external dependency
}

Good unit tests are:

fast

isolated

repeatable

deterministic

You want the same result every time.

Organizing Tests

Small projects often place tests in the same file:

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

test "add sums two numbers" {
    // test
}

Larger projects may use separate test files:

src/
    math.zig
    parser.zig

tests/
    math_test.zig
    parser_test.zig

Both approaches are valid.

Keeping tests near the code is often easier for beginners.

Testing Internal Functions

A function does not need to be pub to be tested.

Example:

const std = @import("std");

fn hiddenHelper(x: i32) i32 {
    return x * 10;
}

test "hiddenHelper multiplies by ten" {
    try std.testing.expectEqual(
        @as(i32, 50),
        hiddenHelper(5),
    );
}

This is useful because internal helpers can still contain bugs.

A Good Unit Test Feels Mechanical

Good unit tests are usually boring.

That is a good sign.

A test should read like a checklist:

given this input

call this function

expect this output

Avoid clever tricks or complicated logic inside tests.

If the test itself becomes difficult to understand, it becomes another source of bugs.

A Small Real Example

Here is a complete example:

const std = @import("std");

fn clamp(value: i32, min: i32, max: i32) i32 {
    if (value < min) return min;
    if (value > max) return max;
    return value;
}

test "clamp returns the value when inside the range" {
    try std.testing.expectEqual(
        @as(i32, 5),
        clamp(5, 0, 10),
    );
}

test "clamp returns the minimum when below the range" {
    try std.testing.expectEqual(
        @as(i32, 0),
        clamp(-3, 0, 10),
    );
}

test "clamp returns the maximum when above the range" {
    try std.testing.expectEqual(
        @as(i32, 10),
        clamp(99, 0, 10),
    );
}

Run it:

zig test main.zig

If all tests pass, the function behaves correctly for the cases you checked.

That does not mathematically prove the function is perfect, but it greatly increases confidence that the behavior matches your expectations.