Learn how to use beforeAll, beforeEach, afterEach, and afterAll lifecycle hooks in Zig Test Framework
The Zig Test Framework supports comprehensive lifecycle hooks that allow you to perform setup and teardown operations at various points during test execution. These hooks are inspired by popular testing frameworks like Bun, Vitest, and Jest.
| Hook | Description |
|---|---|
beforeAll |
Runs once before all tests in a describe block |
beforeEach |
Runs before each test |
afterEach |
Runs after each test |
afterAll |
Runs once after all tests in a describe block |
Perform per-test setup and teardown logic with beforeEach and afterEach.
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();
try ztf.describe(allocator, "User Service", struct {
var user_data: ?[]const u8 = null;
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeEach(alloc, setupTest);
try ztf.afterEach(alloc, cleanupTest);
try ztf.it(alloc, "should create a user", testCreateUser);
try ztf.it(alloc, "should update a user", testUpdateUser);
}
fn setupTest(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Setting up test...\n", .{});
user_data = "test_user";
}
fn cleanupTest(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Cleaning up test...\n", .{});
user_data = null;
}
fn testCreateUser(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, user_data != null).toBe(true);
}
fn testUpdateUser(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, user_data != null).toBe(true);
}
}.testSuite);
// Run all tests
const registry = ztf.getRegistry(allocator);
_ = try ztf.runTests(allocator, registry);
ztf.cleanupRegistry();
}Perform per-suite setup and teardown logic with beforeAll and afterAll. The scope is determined by where the hook is defined.
To scope the hooks to a particular describe block:
try ztf.describe(allocator, "Database tests", struct {
var db_connection: ?*Database = null;
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeAll(alloc, setupDatabase);
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 {
_ = alloc;
std.debug.print("Setting up database connection...\n", .{});
// db_connection = try Database.connect();
}
fn teardownDatabase(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Tearing down database connection...\n", .{});
// if (db_connection) |db| db.close();
db_connection = null;
}
fn testQuery(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, db_connection != null).toBe(true);
}
fn testInsert(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, db_connection != null).toBe(true);
}
}.testSuite);Hooks can be nested and will run in the appropriate order:
beforeEachhooks run from outer to inner (parent before child)afterEachhooks run from inner to outer (child before parent)beforeAllhooks run once per suite before any testsafterAllhooks run once per suite after all tests
try ztf.describe(allocator, "Outer suite", struct {
var outer_setup_count: i32 = 0;
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeAll(alloc, outerBeforeAll);
try ztf.beforeEach(alloc, outerBeforeEach);
try ztf.afterEach(alloc, outerAfterEach);
try ztf.afterAll(alloc, outerAfterAll);
try ztf.it(alloc, "outer test", outerTest);
try ztf.describe(alloc, "Inner suite", struct {
var inner_setup_count: i32 = 0;
fn innerSuite(inner_alloc: std.mem.Allocator) !void {
try ztf.beforeAll(inner_alloc, innerBeforeAll);
try ztf.beforeEach(inner_alloc, innerBeforeEach);
try ztf.afterEach(inner_alloc, innerAfterEach);
try ztf.afterAll(inner_alloc, innerAfterAll);
try ztf.it(inner_alloc, "inner test", innerTest);
}
fn innerBeforeAll(inner_alloc: std.mem.Allocator) !void {
_ = inner_alloc;
std.debug.print("Inner beforeAll\n", .{});
}
fn innerBeforeEach(inner_alloc: std.mem.Allocator) !void {
_ = inner_alloc;
inner_setup_count += 1;
std.debug.print("Inner beforeEach\n", .{});
}
fn innerAfterEach(inner_alloc: std.mem.Allocator) !void {
_ = inner_alloc;
std.debug.print("Inner afterEach\n", .{});
}
fn innerAfterAll(inner_alloc: std.mem.Allocator) !void {
_ = inner_alloc;
std.debug.print("Inner afterAll\n", .{});
}
fn innerTest(inner_alloc: std.mem.Allocator) !void {
// Both outer and inner beforeEach hooks have run
try ztf.expect(inner_alloc, outer_setup_count).toBeGreaterThan(0);
try ztf.expect(inner_alloc, inner_setup_count).toBeGreaterThan(0);
}
}.innerSuite);
}
fn outerBeforeAll(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Outer beforeAll\n", .{});
}
fn outerBeforeEach(alloc: std.mem.Allocator) !void {
_ = alloc;
outer_setup_count += 1;
std.debug.print("Outer beforeEach\n", .{});
}
fn outerAfterEach(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Outer afterEach\n", .{});
}
fn outerAfterAll(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Outer afterAll\n", .{});
}
fn outerTest(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, outer_setup_count).toBeGreaterThan(0);
}
}.testSuite);Output order:
Outer beforeAll
Outer beforeEach
outer test
Outer afterEach
Inner beforeAll
Outer beforeEach
Inner beforeEach
inner test
Inner afterEach
Outer afterEach
Inner afterAll
Outer afterAll
try ztf.describe(allocator, "Database operations", struct {
const Connection = struct {
id: u32,
active: bool,
};
var db_initialized: bool = false;
var connection: ?Connection = null;
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeAll(alloc, initializeDatabase);
try ztf.afterAll(alloc, shutdownDatabase);
try ztf.beforeEach(alloc, getConnection);
try ztf.afterEach(alloc, releaseConnection);
try ztf.it(alloc, "should execute query", testQuery);
try ztf.it(alloc, "should insert record", testInsert);
}
fn initializeDatabase(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Initializing database...\n", .{});
db_initialized = true;
}
fn shutdownDatabase(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Shutting down database...\n", .{});
db_initialized = false;
}
fn getConnection(alloc: std.mem.Allocator) !void {
_ = alloc;
connection = Connection{ .id = 1, .active = true };
}
fn releaseConnection(alloc: std.mem.Allocator) !void {
_ = alloc;
if (connection) |*conn| {
conn.active = false;
}
connection = null;
}
fn testQuery(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, db_initialized).toBe(true);
try ztf.expect(alloc, connection != null).toBe(true);
}
fn testInsert(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, db_initialized).toBe(true);
try ztf.expect(alloc, connection != null).toBe(true);
}
}.testSuite);try ztf.describe(allocator, "File operations", struct {
var temp_dir_created: bool = false;
var file_handle: ?[]const u8 = null;
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeAll(alloc, createTempDirectory);
try ztf.afterAll(alloc, cleanupTempDirectory);
try ztf.beforeEach(alloc, openFile);
try ztf.afterEach(alloc, closeFile);
try ztf.it(alloc, "should write to file", testWrite);
try ztf.it(alloc, "should read from file", testRead);
}
fn createTempDirectory(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Creating temp directory...\n", .{});
temp_dir_created = true;
}
fn cleanupTempDirectory(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Cleaning up temp directory...\n", .{});
temp_dir_created = false;
}
fn openFile(alloc: std.mem.Allocator) !void {
_ = alloc;
file_handle = "mock_file_handle";
}
fn closeFile(alloc: std.mem.Allocator) !void {
_ = alloc;
file_handle = null;
}
fn testWrite(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, file_handle != null).toBe(true);
try ztf.expect(alloc, temp_dir_created).toBe(true);
}
fn testRead(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, file_handle != null).toBe(true);
try ztf.expect(alloc, temp_dir_created).toBe(true);
}
}.testSuite);try ztf.describe(allocator, "API Client with mocks", struct {
var api_mock: ?ztf.Mock([]const u8) = null;
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeEach(alloc, setupMocks);
try ztf.afterEach(alloc, resetMocks);
try ztf.it(alloc, "should call API endpoint", testApiCall);
}
fn setupMocks(alloc: std.mem.Allocator) !void {
api_mock = ztf.createMock(alloc, []const u8);
try api_mock.?.mockReturnValue("mock_response");
}
fn resetMocks(alloc: std.mem.Allocator) !void {
_ = alloc;
if (api_mock) |*mock| {
mock.deinit();
}
api_mock = null;
}
fn testApiCall(alloc: std.mem.Allocator) !void {
_ = alloc;
// Test using the mock
if (api_mock) |*mock| {
const response = mock.getReturnValue();
try ztf.expect(alloc, response).toBe(@as(?[]const u8, "mock_response"));
}
}
}.testSuite);If a lifecycle hook throws an error, it affects test execution:
If beforeAll throws an error, all tests in that suite will be skipped:
try ztf.describe(allocator, "Tests with failing beforeAll", struct {
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeAll(alloc, failingSetup);
try ztf.it(alloc, "this test will be skipped", test1);
}
fn failingSetup(alloc: std.mem.Allocator) !void {
_ = alloc;
return error.SetupFailed;
}
fn test1(alloc: std.mem.Allocator) !void {
_ = alloc;
// This won't run
}
}.testSuite);If beforeEach throws an error, the current test will fail and skip to afterEach:
try ztf.describe(allocator, "Tests with failing beforeEach", struct {
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeEach(alloc, failingBefore);
try ztf.afterEach(alloc, cleanupAfter);
try ztf.it(alloc, "this test will fail", test1);
}
fn failingBefore(alloc: std.mem.Allocator) !void {
_ = alloc;
return error.BeforeEachFailed;
}
fn cleanupAfter(alloc: std.mem.Allocator) !void {
_ = alloc;
// This will still run even though beforeEach failed
}
fn test1(alloc: std.mem.Allocator) !void {
_ = alloc;
// This won't run
}
}.testSuite);Errors in afterEach and afterAll are logged but don't affect test results:
try ztf.describe(allocator, "Tests with failing afterEach", struct {
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.afterEach(alloc, failingAfter);
try ztf.it(alloc, "test will pass", test1);
}
fn failingAfter(alloc: std.mem.Allocator) !void {
_ = alloc;
return error.CleanupFailed; // Error is logged but test still passes
}
fn test1(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, true).toBe(true);
}
}.testSuite);// Good: Simple, focused setup
fn beforeEach(alloc: std.mem.Allocator) !void {
_ = alloc;
clearLocalStorage();
resetCounters();
}
// Avoid: Complex logic in hooks makes tests hard to debug
fn beforeEachComplex(alloc: std.mem.Allocator) !void {
const data = try fetchComplexData(alloc);
try processData(data);
try setupMultipleServices(data);
// Too much complexity!
}// Good: Suite-level setup for expensive resources
fn beforeAll(alloc: std.mem.Allocator) !void {
_ = alloc;
try startTestServer(); // Expensive operation, do once
}
// Good: Test-level setup for test-specific state
fn beforeEach(alloc: std.mem.Allocator) !void {
_ = alloc;
user = createTestUser(); // Fresh state for each test
}Always clean up resources in afterEach and afterAll:
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.afterEach(alloc, cleanupTest);
try ztf.afterAll(alloc, cleanupSuite);
// tests...
}
fn cleanupTest(alloc: std.mem.Allocator) !void {
_ = alloc;
// Clean up after each test
clearTemporaryData();
}
fn cleanupSuite(alloc: std.mem.Allocator) !void {
_ = alloc;
// Clean up expensive resources
try closeDatabase();
try stopServer();
}fn setupDatabase(alloc: std.mem.Allocator) !void {
_ = alloc;
database.connect() catch |err| {
std.debug.print("Database setup failed: {any}\n", .{err});
return err; // Re-throw to fail the suite
};
}You can register multiple hooks of the same type, and they will run in the order they were registered:
try ztf.describe(allocator, "Multiple hooks", struct {
fn testSuite(alloc: std.mem.Allocator) !void {
try ztf.beforeEach(alloc, setup1);
try ztf.beforeEach(alloc, setup2);
try ztf.beforeEach(alloc, setup3);
try ztf.afterEach(alloc, cleanup1);
try ztf.afterEach(alloc, cleanup2);
try ztf.it(alloc, "test", testFunc);
}
fn setup1(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Setup 1\n", .{});
}
fn setup2(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Setup 2\n", .{});
}
fn setup3(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Setup 3\n", .{});
}
fn cleanup1(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Cleanup 1\n", .{});
}
fn cleanup2(alloc: std.mem.Allocator) !void {
_ = alloc;
std.debug.print("Cleanup 2\n", .{});
}
fn testFunc(alloc: std.mem.Allocator) !void {
try ztf.expect(alloc, true).toBe(true);
}
}.testSuite);Output:
Setup 1
Setup 2
Setup 3
test
Cleanup 1
Cleanup 2
Registers a function to run once before all tests in the current describe block.
Signature:
pub fn beforeAll(allocator: std.mem.Allocator, hook: HookFn) !voidParameters:
allocator: Memory allocatorhook: Function to run before all tests
Hook Function Type:
pub const HookFn = *const fn (allocator: std.mem.Allocator) anyerror!void;Registers a function to run once after all tests in the current describe block.
Signature:
pub fn afterAll(allocator: std.mem.Allocator, hook: HookFn) !voidRegisters a function to run before each test in the current describe block (and nested blocks).
Signature:
pub fn beforeEach(allocator: std.mem.Allocator, hook: HookFn) !voidRegisters a function to run after each test in the current describe block (and nested blocks).
Signature:
pub fn afterEach(allocator: std.mem.Allocator, hook: HookFn) !voidLifecycle hooks in Zig Test Framework provide powerful capabilities for:
- Setting up and tearing down test fixtures
- Managing shared resources across tests
- Organizing test code with proper setup/cleanup separation
- Handling nested test contexts with inherited hooks
The hooks follow the same patterns as Bun/Vitest/Jest, making them familiar to developers coming from JavaScript/TypeScript ecosystems while maintaining Zig's explicit error handling and memory management principles.