Skip to content

apotema/zspec

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

55 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

ZSpec

CI Coverage

RSpec-like testing framework for Zig.

Features

  • 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 .zon files 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

Installation

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 },
});

Usage

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);
    }
};

Hooks

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.

Skipping Tests

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.

Let (Memoized Values)

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");
    }
};

Matchers

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);

Fluent Matchers

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();

Factory (Test Data Generation)

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 Features

  • Factory.sequence(T) - Auto-incrementing numeric values
  • Factory.sequenceFmt(fmt) - Formatted sequence strings
  • Factory.lazy(fn) / Factory.lazyAlloc(fn) - Computed values
  • Factory.assoc(OtherFactory) - Nested factory associations
  • .trait(overrides) - Create factory variants with different defaults
  • .build(.{}) / .buildPtr(.{}) - Create instances
  • .buildWith(alloc, .{}) / .buildPtrWith(alloc, .{}) - Create with custom allocator
  • Factory.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(), .{});

Loading Factory Definitions from .zon Files

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.

Nested Struct Support

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 .zon fields 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

Fixture (Static Test Data)

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 Features

  • Fixture.define(T, data) - Define a fixture from inline data or .zon file
  • .create(.{}) - Create an instance with optional field overrides
  • Scenario fixtures - Struct of structs for related test data
  • Fixed-size arrays - [N]T fields populated from .zon tuples
  • Nested struct coercion - Anonymous structs coerce to named types recursively
  • Compile-time validation - Field name typos caught at compile time

Optional Integrations

ECS Integration (zig-ecs)

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

FSM Integration (zigfsm)

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

Memory Leak Detection

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 fail

Running Tests

zig build test      # Run zspec's own tests
zig build example   # Run example tests

VS Code Integration

ZSpec includes VS Code configuration for an improved development experience. Open the project in VS Code and you'll get:

Recommended Extensions

Test Explorer

To use the Test Explorer sidebar with ZSpec:

  1. Install the recommended extensions (Test Explorer UI + JUnit Test Adapter)
  2. Run tests with Ctrl+Shift+B (default task generates JUnit XML automatically)
  3. The Test Explorer will display your test results in the sidebar

Tasks

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 tests
  • Tasks: 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 output
  • Tasks: Run Task β†’ ZSpec: Run Tests (Fail First) - Stop on first failure
  • Tasks: Run Task β†’ ZSpec: Run Tests with Filter - Run tests matching a pattern

Keyboard Shortcut

Use Ctrl+Shift+B / Cmd+Shift+B to run the default test task.

Debug Configuration

Debug configurations are available for LLDB debugger:

  1. Build tests with zig build test
  2. Use the Debug panel to select "Debug ZSpec Tests"
  3. Set breakpoints and start debugging

Environment Variables

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)

CI Integration

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

GitHub Actions Example

- 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.xml

GitLab CI Example

test:
  script:
    - TEST_JUNIT_PATH=test-results.xml zig build test
  artifacts:
    reports:
      junit: test-results.xml

Code Coverage

ZSpec supports code coverage using external tools like kcov (Linux).

# Run tests with coverage
kcov --include-pattern=/src/ coverage ./zig-out/bin/test

πŸ“– Full Code Coverage Guide

License

MIT

About

RSpec-like testing framework for Zig

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages