RSpec-like testing framework for Zig.
- describe/context - Organize tests using nested structs
- before/after - Run setup/teardown before/after each test
- beforeAll/afterAll - Run setup/teardown once per scope
- let - Memoized lazy values (computed once per test)
- expect - Custom matchers for readable assertions
- Fluent matchers - RSpec/Jest-style
expect(x).to().equal(y)syntax - Factory - FactoryBot-like test data generation with sequences, lazy values, traits
- Fixture - Static test data snapshots from
.zonfiles with compile-time validation - Scoped hooks - Hooks only apply to tests within their struct
- Skip tests - Skip tests with
skip_prefix - Memory leak detection - Automatic leak detection with configurable behavior
- Smart stack traces - Filtered traces showing source context
Add zspec as a dependency in your build.zig.zon:
.dependencies = .{
.zspec = .{
.url = "https://github.com/apotema/zspec/archive/refs/heads/main.tar.gz",
.hash = "...",
},
},In your build.zig:
const zspec = b.dependency("zspec", .{
.target = target,
.optimize = optimize,
});
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("tests/my_test.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zspec", .module = zspec.module("zspec") },
},
}),
.test_runner = .{ .path = zspec.path("src/runner.zig"), .mode = .simple },
});const std = @import("std");
const zspec = @import("zspec");
const expect = zspec.expect;
test {
zspec.runAll(@This());
}
const Calculator = struct {
value: i32,
pub fn init() Calculator {
return .{ .value = 0 };
}
pub fn add(self: *Calculator, n: i32) void {
self.value += n;
}
pub fn subtract(self: *Calculator, n: i32) void {
self.value -= n;
}
};
pub const Addition = struct {
var calc: Calculator = undefined;
// Scoped hooks - only run for tests in this struct
test "tests:before" {
calc = Calculator.init();
}
test "adds positive numbers" {
calc.add(5);
try expect.equal(calc.value, 5);
}
test "adds negative numbers" {
calc.add(-3);
try expect.equal(calc.value, -3);
}
test "adds multiple times" {
calc.add(10);
calc.add(20);
calc.add(30);
try expect.equal(calc.value, 60);
}
};
pub const Subtraction = struct {
var calc: Calculator = undefined;
test "tests:before" {
calc = Calculator.init();
calc.value = 100;
}
test "subtracts positive numbers" {
calc.subtract(30);
try expect.equal(calc.value, 70);
}
test "can go negative" {
calc.subtract(150);
try expect.equal(calc.value, -50);
}
};| Hook | Suffix | When it runs |
|---|---|---|
beforeAll |
tests:beforeAll |
Once before first test in scope |
afterAll |
tests:afterAll |
Once after all tests in scope |
before |
tests:before |
Before each test in scope |
after |
tests:after |
After each test in scope |
Parent hooks also apply to nested scopes.
Skip tests by prefixing the test name with skip_:
test "skip_not implemented yet" {
// This test will be skipped and reported as such
}
test "skip_waiting for dependency" {
// Also skipped
}Skipped tests are counted separately and shown in the test summary.
const zspec = @import("zspec");
pub const MyTests = struct {
fn createUser() User {
return User.init("test@example.com");
}
const user = zspec.Let(User, createUser);
test "tests:after" {
user.reset(); // Reset for next test
}
test "user has email" {
try zspec.expect.equal(user.get().email, "test@example.com");
}
};const expect = zspec.expect;
// Equality
try expect.equal(calc.value, 5);
try expect.notEqual(user1.id, user2.id);
// Boolean
try expect.toBeTrue(user.active);
try expect.toBeFalse(order.cancelled);
// Optionals / Null
const value: ?i32 = null;
try expect.toBeNull(value);
try expect.notToBeNull(user.email);
// Comparisons
try expect.toBeGreaterThan(user.id, 0);
try expect.toBeLessThan(guest.roles.len, admin.roles.len);
// Strings
try expect.toContain(user.email.?, "@");
// Lengths
const arr = [_]i32{ 1, 2, 3, 4, 5 };
try expect.toHaveLength(&arr, 5);
try expect.toBeEmpty(guest.roles);
try expect.notToBeEmpty(user.roles);ZSpec also provides RSpec/Jest-style fluent matchers with to() and notTo() syntax:
const expectFluent = zspec.expectFluent;
// Equality
try expectFluent(@as(i32, 42)).to().equal(42);
try expectFluent(@as([]const u8, "hello")).to().eql("hello"); // deep equality
try expectFluent(@as(i32, 1)).notTo().equal(2);
// Booleans
try expectFluent(true).to().beTrue();
try expectFluent(false).to().beFalse();
// Null checks
try expectFluent(optional).to().beNull();
try expectFluent(optional).notTo().beNull();
// Comparisons
try expectFluent(@as(i32, 10)).to().beGreaterThan(5);
try expectFluent(@as(i32, 5)).to().beLessThan(10);
try expectFluent(@as(i32, 5)).to().beGreaterThanOrEqual(5);
try expectFluent(@as(i32, 5)).to().beLessThanOrEqual(10);
try expectFluent(@as(i32, 5)).to().beBetween(1, 10);
// Strings/Slices
try expectFluent(@as([]const u8, "hello world")).to().contain("world");
try expectFluent(@as([]const u8, "hello")).to().startWith("hel");
try expectFluent(@as([]const u8, "hello")).to().endWith("llo");
try expectFluent(@as([]const u8, "hello")).to().haveLength(5);
try expectFluent(@as([]const u8, "")).to().beEmpty();ZSpec includes a FactoryBot-like module for generating test data:
const Factory = zspec.Factory;
const User = struct {
id: u32,
name: []const u8,
email: []const u8,
active: bool,
};
// Define a factory with default values
const UserFactory = Factory.define(User, .{
.id = Factory.sequence(u32), // Auto-incrementing
.name = "John Doe",
.email = Factory.sequenceFmt("user{d}@example.com"), // "user1@...", "user2@..."
.active = true,
});
// Create trait variants
const AdminFactory = UserFactory.trait(.{ .role = "admin" });
pub const UserTests = struct {
test "tests:before" {
Factory.resetSequences(); // Reset sequences before each test
}
test "creates user with defaults" {
const user = UserFactory.build(.{});
try expect.equal(user.id, 1);
try expect.toBeTrue(std.mem.eql(u8, user.email, "user1@example.com"));
}
test "creates user with overrides" {
const user = UserFactory.build(.{ .name = "Jane Doe" });
try expect.toBeTrue(std.mem.eql(u8, user.name, "Jane Doe"));
}
test "creates pointer with buildPtr" {
const user_ptr = UserFactory.buildPtr(.{});
defer std.testing.allocator.destroy(user_ptr);
}
};Factory.sequence(T)- Auto-incrementing numeric valuesFactory.sequenceFmt(fmt)- Formatted sequence stringsFactory.lazy(fn)/Factory.lazyAlloc(fn)- Computed valuesFactory.assoc(OtherFactory)- Nested factory associations.trait(overrides)- Create factory variants with different defaults.build(.{})/.buildPtr(.{})- Create instances.buildWith(alloc, .{})/.buildPtrWith(alloc, .{})- Create with custom allocatorFactory.resetSequences()- Reset all sequence counters
Note: When using sequenceFmt, use an arena allocator to avoid memory leak reports:
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const user = UserFactory.buildWith(arena.allocator(), .{});For better separation of concerns, you can load factory definitions from .zon files using Factory.defineFrom():
// factories.zon
.{
.user = .{
.id = 0,
.name = "John Doe",
.email = "john@example.com",
.active = true,
},
.admin = .{
.id = 0,
.name = "Admin User",
.email = "admin@example.com",
.active = true,
},
}// In your test file
const factory_defs = @import("factories.zon");
const UserFactory = Factory.defineFrom(User, factory_defs.user);
const AdminFactory = Factory.defineFrom(User, factory_defs.admin);
// Use like any other factory
const user = UserFactory.build(.{});
const custom = UserFactory.build(.{ .name = "Jane" });Benefits:
- Typo detection:
defineFrom()validates field names at compile time (including nested structs) - Separation of concerns: Test data lives in data files, test logic in test files
- Reusability: Share factory definitions across multiple test files
- Type safety: Full compile-time type checking via Zig's comptime system
Note: .zon files contain static comptime data only. For dynamic features like sequences or lazy values, use define() directly or apply them via traits.
Factory.defineFrom() supports deeply nested structs with automatic coercion and validation:
const Color = struct { r: u8, g: u8, b: u8, a: u8 };
const SpriteVisual = struct { tint: Color, scale: f32 };
// Nested anonymous structs are automatically coerced to named types
const sprite_zon = .{
.tint = .{ .r = 255, .g = 128, .b = 64, .a = 255 },
.scale = 1.5,
};
const SpriteFactory = Factory.defineFrom(SpriteVisual, sprite_zon);
// Works with arbitrarily deep nesting (struct within struct within struct)
const Inner = struct { value: u8 };
const Middle = struct { inner: Inner };
const Outer = struct { middle: Middle };
const outer_zon = .{
.middle = .{ .inner = .{ .value = 42 } },
};
const OuterFactory = Factory.defineFrom(Outer, outer_zon);
// Anonymous struct overrides work at build() callsite too
const sprite = SpriteFactory.build(.{
.tint = .{ .r = 0, .g = 255, .b = 0, .a = 128 },
});Features:
- Recursive coercion: Nested anonymous structs coerce to named types at any depth
- Recursive validation: Typos in nested
.zonfields are caught at compile time - Union support: Unions with nested struct payloads are validated and coerced
- Callsite overrides: Anonymous structs work in
.build()and.trait()calls
ZSpec includes a Fixture module for static, pre-defined test data. Unlike Factory (which supports sequences, lazy values, and traits), Fixture is designed for known-good data snapshots.
const Fixture = zspec.Fixture;
const User = struct { id: u32, name: []const u8, email: []const u8 };
const Product = struct { id: u32, name: []const u8, price: f32, seller_id: u32 };
const Order = struct { id: u32, user_id: u32, product_id: u32, quantity: u32 };
// Define from .zon file
const fixture_data = @import("fixtures/user.zon");
const UserFixture = Fixture.define(User, fixture_data);
// Or define inline
const CheckoutScenario = struct { user: User, product: Product, order: Order };
const CheckoutFixture = Fixture.define(CheckoutScenario, .{
.user = .{ .id = 1, .name = "John Doe", .email = "john@example.com" },
.product = .{ .id = 10, .name = "Widget", .price = 29.99, .seller_id = 1 },
.order = .{ .id = 100, .user_id = 1, .product_id = 10, .quantity = 2 },
});test "create with defaults" {
const user = UserFixture.create(.{});
try expect.equal(user.id, 1);
try std.testing.expectEqualStrings("John Doe", user.name);
}
test "override fields at create time" {
const user = UserFixture.create(.{ .name = "Jane Smith" });
try std.testing.expectEqualStrings("Jane Smith", user.name);
try expect.equal(user.id, 1); // default preserved
}
test "scenario with cross-references" {
const s = CheckoutFixture.create(.{});
try expect.equal(s.order.user_id, s.user.id);
try expect.equal(s.order.product_id, s.product.id);
}Fixture.define(T, data)- Define a fixture from inline data or.zonfile.create(.{})- Create an instance with optional field overrides- Scenario fixtures - Struct of structs for related test data
- Fixed-size arrays -
[N]Tfields populated from.zontuples - Nested struct coercion - Anonymous structs coerce to named types recursively
- Compile-time validation - Field name typos caught at compile time
ZSpec provides an optional zspec-ecs module for testing Entity Component Systems with zig-ecs.
const ECS = @import("zspec-ecs");
test "creates entities with components" {
const registry = ECS.createRegistry(ecs.Registry);
defer ECS.destroyRegistry(registry);
const entity = ECS.createEntity(registry, .{
.position = PositionFactory.build(.{}),
.health = HealthFactory.build(.{}),
});
}π Full ECS Integration Guide | Examples | Usage Project
ZSpec provides an optional zspec-fsm module for testing Finite State Machines with zigfsm.
const FSM = @import("zspec-fsm");
test "state transitions" {
var fsm = MyFSM.init();
defer fsm.deinit();
// Bulk transition setup
try FSM.addTransitions(MyFSM, &fsm, &.{
.{ .event = .start, .from = .idle, .to = .running },
.{ .event = .stop, .from = .running, .to = .stopped },
});
// Test event sequence
try FSM.applyEventsAndVerify(MyFSM, &fsm, &.{ .start, .stop }, .stopped);
}π Full FSM Integration Guide | Examples | Usage Project
ZSpec automatically detects memory leaks using Zig's std.testing.allocator. By default, tests that leak memory will fail.
const allocator = zspec.allocator;
test "properly cleaned up allocation does not leak" {
const data = try allocator.alloc(u8, 100);
defer allocator.free(data);
@memset(data, 0);
try expect.equal(data.len, 100);
}
test "multiple allocations properly freed" {
const allocs = try allocator.alloc([*]u8, 5);
defer allocator.free(allocs);
for (allocs, 0..) |_, i| {
const block = try allocator.alloc(u8, 64);
allocs[i] = block.ptr;
}
for (allocs) |ptr| {
allocator.free(ptr[0..64]);
}
try expect.equal(allocs.len, 5);
}Use errdefer for allocations that should be freed on error paths:
test "errdefer prevents leaks on error" {
const data = try allocator.alloc(u8, 100);
errdefer allocator.free(data);
// If this fails, errdefer frees data automatically
try processData(data);
allocator.free(data);
}Control leak detection behavior with environment variables:
TEST_DETECT_LEAKS=false zig build test # Disable leak detection
TEST_FAIL_ON_LEAK=false zig build test # Report leaks but don't failzig build test # Run zspec's own tests
zig build example # Run example testsZSpec includes VS Code configuration for an improved development experience. Open the project in VS Code and you'll get:
- Zig Language - Zig language support with ZLS
- Test Explorer UI - Test Explorer sidebar panel
- JUnit Test Adapter - JUnit XML support for Test Explorer
To use the Test Explorer sidebar with ZSpec:
- Install the recommended extensions (Test Explorer UI + JUnit Test Adapter)
- Run tests with
Ctrl+Shift+B(default task generates JUnit XML automatically) - The Test Explorer will display your test results in the sidebar
Run tests directly from VS Code using the Command Palette (Ctrl+Shift+P / Cmd+Shift+P):
Tasks: Run TaskβZSpec: Run All Tests- Run unit testsTasks: Run TaskβZSpec: Run Example Tests- Run example test file (tests/example_test.zig)Tasks: Run TaskβZSpec: Run All Examples- Run all example files (examples/*.zig)Tasks: Run TaskβZSpec: Run Tests (Verbose)- Run with verbose outputTasks: Run TaskβZSpec: Run Tests (Fail First)- Stop on first failureTasks: Run TaskβZSpec: Run Tests with Filter- Run tests matching a pattern
Use Ctrl+Shift+B / Cmd+Shift+B to run the default test task.
Debug configurations are available for LLDB debugger:
- Build tests with
zig build test - Use the Debug panel to select "Debug ZSpec Tests"
- Set breakpoints and start debugging
| Variable | Default | Description |
|---|---|---|
TEST_VERBOSE |
true |
Show each test result |
TEST_FAIL_FIRST |
false |
Stop on first failure |
TEST_FILTER |
- | Only run tests matching pattern |
TEST_JUNIT_PATH |
- | Generate JUnit XML report at specified path |
TEST_DETECT_LEAKS |
true |
Enable memory leak detection |
TEST_FAIL_ON_LEAK |
true |
Fail tests that leak memory |
TEST_FAILED_ONLY |
false |
Only show failed test results |
TEST_OUTPUT_FILE |
- | Write test output to file (without ANSI codes) |
ZSpec can generate JUnit XML reports for integration with CI systems like Jenkins, GitHub Actions, GitLab CI, and others.
# Generate JUnit XML report
TEST_JUNIT_PATH=test-results.xml zig build test- name: Run tests
run: TEST_JUNIT_PATH=test-results.xml zig build test
- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results.xmltest:
script:
- TEST_JUNIT_PATH=test-results.xml zig build test
artifacts:
reports:
junit: test-results.xmlZSpec supports code coverage using external tools like kcov (Linux).
# Run tests with coverage
kcov --include-pattern=/src/ coverage ./zig-out/bin/testMIT