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:
- create input
- call the function
- 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 u8Use 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.zigBoth 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.zigIf 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.