Zig Test Framework
On this page 45
A modern, feature-rich testing framework for Zig inspired by Jest, Vitest, and Bun's test runner.
Features
- Test Discovery - Automatic discovery of
*.test.zigfiles - Code Coverage - Line, branch, and function coverage with HTML reports (via kcov/grindcov)
- Async Test Support - Full async test execution with concurrent and sequential modes
- Timeout Handling - Configurable timeouts at test, suite, and global levels with extension support
- Familiar API - Describe/it syntax similar to Jest and Vitest
- Rich Assertions - Comprehensive assertion library with
.expect()and matchers - Error Assertions -
toThrow()andtoThrowError()for testing error handling - Lifecycle Hooks - beforeEach, afterEach, beforeAll, afterAll support (with async support and nested scoping)
- Multiple Reporters - Spec, Dot, JSON, TAP, and JUnit reporters built-in
- Mocking & Spying - Full Jest-compatible mocking API with call tracking, return values, and spies
- Snapshot Testing - Compare outputs against saved snapshots with multiple formats (JSON, pretty text, compact)
- Time Mocking - Control time in tests with
setSystemTime()and Jest-compatible time APIs - Advanced Matchers - Floating-point comparison, array/struct matchers, and more
- CLI Support - Full command-line interface with filtering and options
- Nested Suites - Support for nested describe blocks
- Test Filtering - Skip tests with
.skip()or focus with.only() - Colorized Output - Beautiful, readable test output with colors
- Watch Mode - Automatically re-run tests on file changes
- Memory Profiling - Track memory usage and detect leaks
- Parallel Execution - Run tests in parallel for faster execution
- Configuration Files - YAML/JSON/TOML config file support
Installation
As a Dependency
Add to your build.zig.zon:
.{
.name = "my-project",
.version = "0.1.0",
.dependencies = .{
.@"zig-test-framework" = .{
.url = "https://github.com/zig-utils/zig-test-framework/archive/refs/tags/v0.1.0.tar.gz",
// Replace with actual hash after publishing
},
},
}
Quick Start
- Clone this repository
- Run
zig buildto build the framework - Run
zig build testto run the framework's self-tests - Run
zig build examplesto run the example tests
Usage
There are two ways to use the Zig Test Framework:
- Test Discovery Mode - Automatically discover and run
*.test.zigfiles (recommended) - Programmatic Mode - Manually register tests using
describe()andit()
Test Discovery Mode (Recommended)
The easiest way to use the framework is with automatic test discovery:
- Create test files with the
.test.zigextension:
// tests/math.test.zig
const std = @import("std");
test "addition works" {
const result = 2 + 2;
try std.testing.expectEqual(@as(i32, 4), result);
}
test "subtraction works" {
const result = 10 - 5;
try std.testing.expectEqual(@as(i32, 5), result);
}
- Run all tests:
zig-test --test-dir tests
This will automatically discover and run all *.test.zig files in the tests directory.
CLI Options for Test Discovery:
--test-dir <dir>- Directory to search for tests (default: current directory)--pattern <pattern>- File pattern to match (default:*.test.zig)--no-recursive- Disable recursive directory search--bail- Stop on first failure--verbose- Show detailed output
Examples:
# Run all tests in tests/ directory
zig-test --test-dir tests
# Run tests with custom pattern
zig-test --test-dir src --pattern "*.spec.zig"
# Run tests without recursion
zig-test --test-dir tests --no-recursive
# Stop on first failure
zig-test --test-dir tests --bail
Programmatic Mode
For more control, you can manually register tests:
const std = @import("std");
const ztf = @import("zig-test-framework");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer * = gpa.deinit();
const allocator = gpa.allocator();
// Define a test suite
try ztf.describe(allocator, "Math operations", struct {
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.it(alloc, "should add two numbers", testAddition);
try ztf.it(alloc, "should subtract two numbers", testSubtraction);
}
fn testAddition(alloc: std.mem.Allocator) !void {
const result = 2 + 2;
try ztf.expect(alloc, result).toBe(4);
}
fn testSubtraction(alloc: std.mem.Allocator) !void {
const result = 10 - 5;
try ztf.expect(alloc, result).toBe(5);
}
}.testSuite);
// Run all tests
const registry = ztf.getRegistry(allocator);
const success = try ztf.runTests(allocator, registry);
// Clean up the registry
ztf.cleanupRegistry();
if (!success) {
std.process.exit(1);
}
}
Assertions
// Basic equality
try expect(alloc, 5).toBe(5);
try expect(alloc, true).toBeTruthy();
try expect(alloc, false).toBeFalsy();
// Negation
try expect(alloc, 5).not().toBe(10);
// Comparisons
try expect(alloc, 10).toBeGreaterThan(5);
try expect(alloc, 10).toBeGreaterThanOrEqual(10);
try expect(alloc, 5).toBeLessThan(10);
try expect(alloc, 5).toBeLessThanOrEqual(5);
// Strings
try expect(alloc, "hello").toBe("hello");
try expect(alloc, "hello world").toContain("world");
try expect(alloc, "hello").toStartWith("hel");
try expect(alloc, "hello").toEndWith("lo");
try expect(alloc, "hello").toHaveLength(5);
// Optionals
const value: ?i32 = null;
try expect(alloc, value).toBeNull();
const defined: ?i32 = 42;
try expect(alloc, defined).toBeDefined();
// Arrays/Slices
const numbers = [*]i32{1, 2, 3, 4, 5};
const matcher = expectArray(alloc, &numbers);
try matcher.toHaveLength(5);
try matcher.toContain(3);
try matcher.toContainAll(&[*]i32{1, 3, 5});
// Error assertions
const FailingFn = struct {
fn call() !void {
return error.TestError;
}
};
try expect(alloc, FailingFn.call).toThrow();
try expect(alloc, FailingFn.call).toThrowError(error.TestError);
Advanced Matchers
// Floating-point comparison
try ztf.toBeCloseTo(0.1 + 0.2, 0.3, 10);
// NaN and Infinity
try ztf.toBeNaN(std.math.nan(f64));
try ztf.toBeInfinite(std.math.inf(f64));
// Struct matching
const User = struct {
name: []const u8,
age: u32,
};
const user = User{ .name = "Alice", .age = 30 };
const matcher = ztf.expectStruct(alloc, user);
try matcher.toHaveField("name", "Alice");
try matcher.toHaveField("age", @as(u32, 30));
Test Hooks
try ztf.describe(allocator, "Database tests", struct {
var db*connection: ?*Database = null;
fn testSuite(alloc: std.mem.Allocator) !void {
// Runs once before all tests
try ztf.beforeAll(alloc, setupDatabase);
// Runs before each test
try ztf.beforeEach(alloc, openConnection);
// Runs after each test
try ztf.afterEach(alloc, closeConnection);
// Runs once after all tests
try ztf.afterAll(alloc, teardownDatabase);
try ztf.it(alloc, "should query data", testQuery);
try ztf.it(alloc, "should insert data", testInsert);
}
fn setupDatabase(alloc: std.mem.Allocator) !void {
// Initialize database
}
fn teardownDatabase(alloc: std.mem.Allocator) !void {
// Cleanup database
}
fn openConnection(alloc: std.mem.Allocator) !void {
// Open DB connection
}
fn closeConnection(alloc: std.mem.Allocator) !void {
// Close DB connection
}
fn testQuery(alloc: std.mem.Allocator) !void {
// Test implementation
}
fn testInsert(alloc: std.mem.Allocator) !void {
// Test implementation
}
}.testSuite);
Memory Management
Important: Always call cleanupRegistry() after running tests to prevent memory leaks:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer * = gpa.deinit();
const allocator = gpa.allocator();
// Define your test suites...
try ztf.describe(allocator, "My tests", ...);
// Run tests
const registry = ztf.getRegistry(allocator);
const success = try ztf.runTests(allocator, registry);
// Clean up - IMPORTANT!
ztf.cleanupRegistry();
if (!success) {
std.process.exit(1);
}
}
The cleanupRegistry() function frees all test suites, test cases, and associated resources. Without it, you'll see memory leaks when using GeneralPurposeAllocator in debug mode.
Nested Describe Blocks
try ztf.describe(allocator, "User Service", struct {
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.describe(alloc, "Authentication", struct {
fn nestedSuite(nested*alloc: std.mem.Allocator) !void {
try ztf.it(nested*alloc, "should login with valid credentials", testValidLogin);
try ztf.it(nested*alloc, "should reject invalid credentials", testInvalidLogin);
}
fn testValidLogin(nested*alloc: std.mem.Allocator) !void {
// Test implementation
}
fn testInvalidLogin(nested*alloc: std.mem.Allocator) !void {
// Test implementation
}
}.nestedSuite);
}
}.testSuite);
Mocking and Spying
Full Jest-compatible mocking API:
// Create a mock
var mock*fn = ztf.createMock(alloc, i32);
defer mock*fn.deinit();
// Record calls and assert
try mock*fn.recordCall("arg1");
try mock*fn.toHaveBeenCalled();
try mock*fn.toHaveBeenCalledTimes(1);
try mock*fn.toHaveBeenCalledWith("arg1");
// Mock return values
* = try mock*fn.mockReturnValue(42);
* = try mock*fn.mockReturnValueOnce(100);
const value = mock*fn.getReturnValue(); // Returns 100 first time, then 42
// Spy on existing functions
const original: i32 = 99;
var spy = ztf.createSpy(alloc, i32, original);
defer spy.deinit();
try spy.call("test");
try spy.toHaveBeenCalled();
const restored = spy.mockRestore();
See docs/mocks.md for complete mocking documentation.
Snapshot Testing
Capture and compare test outputs:
// Create a snapshot
var snap = ztf.createSnapshot(alloc, "user*test", .{
.update = true,
.format = .json,
});
// Match against snapshot
const user = User{ .name = "Alice", .age = 30 };
try snap.match(user);
// Named snapshots
try snap.matchNamed("initial*state", initial);
try snap.matchNamed("after*update", updated);
// String snapshots
try snap.matchString("Expected output");
Snapshots are stored in .snapshots/ directory. Run tests with update: true to create/update snapshots.
See docs/snapshots.md for complete snapshot documentation.
Time Mocking
Control time in your tests:
// Set specific time (Jan 1, 2020)
ztf.setSystemTime(alloc, 1577836800000);
const year = ztf.DateHelper.getYear(ztf.time.now(alloc));
try ztf.expect(alloc, year).toBe(@as(u16, 2020));
// Advance time
ztf.advanceTimersByTime(alloc, 60000); // Advance 1 minute
// Jest-compatible API
ztf.jest.useFakeTimers(alloc);
ztf.jest.setSystemTime(alloc, 1577836800000);
const current = ztf.jest.now(alloc);
ztf.jest.advanceTimersByTime(alloc, 5000);
ztf.jest.useRealTimers(alloc);
// Reset to real time
ztf.setSystemTime(alloc, null);
See docs/dates-and-times.md for complete time mocking documentation.
Test Filtering
// Skip a test
try ztf.itSkip(alloc, "should skip this test", testSkipped);
// Skip an entire suite
try ztf.describeSkip(allocator, "Skipped suite", struct {
// All tests in this suite will be skipped
}.testSuite);
// Run only specific tests
try ztf.itOnly(alloc, "should run only this test", testOnly);
// Run only specific suites
try ztf.describeOnly(allocator, "Only this suite", struct {
// Only tests in this suite will run
}.testSuite);
Async Tests
Run tests asynchronously with full concurrency support:
// Basic async test
try ztf.itAsync(allocator, "async operation", struct {
fn run(alloc: std.mem.Allocator) !void {
* = alloc;
std.Thread.sleep(100 * std.time.ns*per*ms);
// Test async operations
}
}.run);
// Async test with custom timeout
try ztf.itAsyncTimeout(allocator, "slow async operation", asyncTestFn, 10000);
// Skip/only for async tests
try ztf.itAsyncSkip(allocator, "skipped async test", testFn);
try ztf.itAsyncOnly(allocator, "focused async test", testFn);
// Using AsyncTestExecutor for advanced control
var executor = ztf.AsyncTestExecutor.init(allocator, .{
.concurrent = true,
.max*concurrent = 5,
.default*timeout*ms = 5000,
});
defer executor.deinit();
try executor.registerTest("test1", testFn1);
try executor.registerTest("test2", testFn2);
const results = try executor.executeAll();
defer allocator.free(results);
Timeout Handling
Configure timeouts at multiple levels:
// Per-test timeout (1 second)
try ztf.itTimeout(allocator, "timed test", testFn, 1000);
// Per-suite timeout (5 seconds for all tests in suite)
try ztf.describeTimeout(allocator, "Timed Suite", 5000, struct {
fn suite(alloc: std.mem.Allocator) !void {
try ztf.it(alloc, "test 1", test1);
try ztf.it(alloc, "test 2", test2);
}
}.suite);
// Global timeout configuration
const global*config = ztf.GlobalTimeoutConfig{
.default*timeout*ms = 5000,
.enabled = true,
.allow*extension = true,
.max*extension*ms = 30000,
};
var enforcer = ztf.TimeoutEnforcer.init(allocator, global*config);
defer enforcer.deinit();
// Timeout context for manual control
var context = ztf.TimeoutContext.init(allocator, 1000);
context.start();
// Extend timeout if needed
try context.extend(500);
// Check status
if (context.isTimedOut()) {
// Handle timeout
}
context.complete();
var result = try context.getResult();
defer result.deinit();
CLI Usage
# Run all tests
zig-test
# Show help
zig-test --help
# Use different reporter
zig-test --reporter dot
zig-test --reporter json
# Filter tests by name
zig-test --filter "user"
# Stop on first failure
zig-test --bail
# Disable colors
zig-test --no-color
CLI Options
| Option | Alias | Description |
|---|---|---|
--help | -h | Show help message |
--version | -v | Show version information |
--bail | -b | Stop on first failure |
--filter <pattern> | Run only tests matching pattern | |
--reporter <name> | Set reporter (spec, dot, json) | |
--verbose | Enable verbose output | |
--quiet | -q | Minimal output |
--no-color | Disable colored output | |
--test-dir <dir> | Directory to search for tests | |
--pattern <pattern> | Test file pattern (default: *.test.zig) | |
--no-recursive | Disable recursive directory search | |
--coverage | Enable code coverage collection | |
--coverage-dir <dir> | Coverage output directory (default: coverage) | |
--coverage-tool <tool> | Coverage tool to use (kcov or grindcov) |
Code Coverage
The framework integrates with external coverage tools to provide comprehensive code coverage reporting including line, branch, and function coverage.
Prerequisites
Install one of the supported coverage tools:
kcov (recommended):
# macOS
brew install kcov
# Ubuntu/Debian
sudo apt-get install kcov
# From source
git clone https://github.com/SimonKagstrom/kcov.git
cd kcov
cmake . && make && sudo make install
grindcov:
# Install Valgrind first
brew install valgrind # macOS
sudo apt-get install valgrind # Ubuntu/Debian
# Then install grindcov
pip install grindcov
Using Coverage
Enable coverage collection with the --coverage flag when running tests:
# Enable coverage with default settings (kcov)
zig-test --test-dir tests --coverage
# Specify custom coverage directory
zig-test --test-dir tests --coverage --coverage-dir my-coverage
# Use grindcov instead of kcov
zig-test --test-dir tests --coverage --coverage-tool grindcov
Coverage Output
When coverage is enabled, you'll see a coverage summary after test execution:
Test Summary:
Files run: 5
Passed: 5
Failed: 0
==== Coverage Summary ====
Line Coverage: 245/300 (81.67%)
Function Coverage: 45/50 (90.00%)
Branch Coverage: 120/150 (80.00%)
Coverage report: coverage/index.html
==============
HTML Coverage Reports
Coverage reports are generated in HTML format and can be viewed in your browser:
# Run tests with coverage
zig-test --test-dir tests --coverage
# Open the HTML report
open coverage/index.html # macOS
xdg-open coverage/index.html # Linux
The HTML report provides:
- Line Coverage: Shows which lines of code were executed
- Branch Coverage: Shows which code branches were taken
- Function Coverage: Shows which functions were called
- File-by-file breakdown: Detailed coverage for each source file
- Color-coded highlighting: Red for uncovered lines, green for covered
Coverage Features
- Automatic integration: Coverage collection is seamlessly integrated with test discovery
- Multiple test files: Coverage is aggregated across all test files
- HTML reports: Rich, interactive HTML reports with syntax highlighting
- Graceful fallback: If coverage tool is not installed, tests run normally without coverage
- Flexible configuration: Choose your coverage tool and output directory
Coverage Best Practices
- Install kcov: It's faster and more mature than grindcov
- Regular coverage checks: Run coverage regularly during development
- Set coverage goals: Aim for >80% line coverage as a baseline
- Review uncovered code: Use the HTML report to identify untested code paths
- CI/CD integration: Add coverage checks to your continuous integration pipeline
Reporters
Spec Reporter (Default)
Beautiful hierarchical output with colors:
Running 12 test(s)...
Math operations
✓ should add two numbers (0.05ms)
✓ should subtract two numbers (0.03ms)
✓ should multiply two numbers (0.04ms)
String operations
✓ should compare strings (0.02ms)
✓ should check substring (0.03ms)
Test Summary:
Total: 12
Passed: 12
Time: 1.23ms
Dot Reporter
Minimal output for CI environments:
Running 12 tests:
............
Passed: 12, Failed: 0, Total: 12 (1.23ms)
JSON Reporter
Machine-readable output for tooling:
{
"totalTests": 12,
"tests": [
{
"name": "should add two numbers",
"status": "passed",
"time": 0.05
}
],
"summary": {
"total": 12,
"passed": 12,
"failed": 0,
"skipped": 0,
"time": 1.23
}
}
API Reference
See docs/api.md for complete API documentation.
Examples
Check out the examples/ directory for comprehensive examples:
examples/basic*test.zig- Basic assertions and test structureexamples/advanced*test.zig- Hooks, matchers, mocking, and advanced featuresexamples/async*tests.zig- Async test execution (8 scenarios)examples/timeout*examples.zig- Timeout handling (10 scenarios)- See
ASYNC*TEST*COMPLETE.mdandTIMEOUT*COMPLETE.mdfor detailed documentation
Building from Source
# Clone the repository
git clone https://github.com/zig-utils/zig-test-framework.git
cd zig-test-framework
# Build the framework
zig build
# Run self-tests
zig build test
# Run examples
zig build examples
Requirements
- Zig 0.15.1 or later
Documentation
Comprehensive guides and API documentation:
Core Features
- Lifecycle Hooks - beforeAll, beforeEach, afterEach, afterAll with nested scoping
- Mocking & Spying - Complete Jest-compatible mocking API with 20+ methods
- Snapshot Testing - Multiple formats, named snapshots, and diff visualization
- Date & Time Mocking - Control time with
setSystemTime()and Jest-compatible APIs
Additional Resources
- Examples - Basic and advanced usage examples
- Test Files - Comprehensive test suites demonstrating all features
- API Reference - Full source code documentation
Quick Links
- Run specific test suites:
zig build test-hooks- Lifecycle hooks testszig build test-mocks- Mocking and spying testszig build test-snapshots- Snapshot testing testszig build test-time- Time mocking tests
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
Community
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
For casual chit-chat with others using this package:
Join the zig-utils Discord Server
License
MIT License - see LICENSE for details.
Roadmap
- Snapshot testing
- Async/await test support
- Test timeout handling
- Code coverage reporting
- Watch mode for file changes
- TAP/JUnit reporters
- Memory profiling
- Configuration files (YAML/JSON/TOML)
- Parallel test execution
- Parameterized tests (it.each)
- Property-based testing
- IDE integration
- Performance benchmarking
Acknowledgments
Inspired by:
- Jest - JavaScript testing framework
- Vitest - Vite-native testing framework
- Bun Test - Bun's built-in test runner
Support
- GitHub Issues: https://github.com/zig-utils/zig-test-framework/issues
- Documentation: https://github.com/zig-utils/zig-test-framework/tree/main/docs
Made with ❤️ for the Zig community