Skip to content

FSM Integration

ZSpec Bot edited this page Nov 26, 2025 · 1 revision

FSM Integration with zigfsm

ZSpec provides comprehensive integration with zigfsm to make testing Finite State Machines easier and more expressive using helper functions and builder patterns.

Table of Contents

Installation

Add both ZSpec and zigfsm to your build.zig.zon:

.dependencies = .{
    .zspec = .{
        .url = "https://github.com/apotema/zspec/archive/refs/heads/main.tar.gz",
        .hash = "...",
    },
    .zigfsm = .{
        .url = "https://github.com/cryptocode/zigfsm/archive/refs/heads/main.tar.gz",
        .hash = "...",
    },
},

In your test file:

const zspec = @import("zspec");
const zigfsm = @import("zigfsm");
const FSM = @import("zspec-fsm");
const expect = zspec.expect;
const Factory = zspec.Factory;

Quick Start

Define Your State Machine

const State = enum {
    idle,
    running,
    stopped,
};

const Event = enum {
    start,
    stop,
    reset,
};

const MyFSM = zigfsm.StateMachine(State, Event, .idle);

Basic Test Setup

pub const FSMTests = struct {
    var fsm: MyFSM = undefined;

    test "tests:before" {
        Factory.resetSequences();
        fsm = MyFSM.init();

        // Bulk transition setup
        try FSM.addTransitions(MyFSM, &fsm, &.{
            .{ .event = .start, .from = .idle, .to = .running },
            .{ .event = .stop, .from = .running, .to = .stopped },
            .{ .event = .reset, .from = .stopped, .to = .idle },
        });
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "state transitions work" {
        try fsm.do(.start);
        try expect.toBeTrue(fsm.isCurrently(.running));

        try fsm.do(.stop);
        try expect.toBeTrue(fsm.isCurrently(.stopped));
    }
};

Core Concepts

Transition Management

ZSpec provides helpers to configure FSM transitions:

  • FSM.addTransitions(FSMType, fsm, transitions) - Bulk transition setup
  • FSM.Transition(State, Event) - Transition type for bulk operations
  • Uses zigfsm's addEventAndTransition() under the hood

Event Sequence Testing

Test complex state flows with event sequences:

// Apply multiple events and verify final state
try FSM.applyEventsAndVerify(MyFSM, &fsm, &.{
    .start,
    .stop,
    .reset,
}, .idle);

State Validation Helpers

Validate state transition possibilities:

// Verify these states are reachable
try FSM.expectValidNextStates(MyFSM, &fsm, &.{.running});

// Verify these states are NOT reachable
try FSM.expectInvalidNextStates(MyFSM, &fsm, &.{.stopped});

Builder Pattern

Fluently configure FSMs:

const Builder = FSM.FSMBuilder(MyFSM);

var builder = Builder.init();
_ = try builder.withEvent(.start, .idle, .running);
_ = try builder.withEvent(.stop, .running, .stopped);

var fsm = builder.build();
defer fsm.deinit();

API Reference

Transition Functions

Transition(comptime State: type, comptime Event: type)

Creates a transition descriptor type for bulk operations.

Fields:

  • event: Event - The event that triggers the transition
  • from: State - The source state
  • to: State - The destination state

Example:

const MyTransition = FSM.Transition(State, Event);
const t = MyTransition{
    .event = .start,
    .from = .idle,
    .to = .running,
};

addTransitions(comptime FSMType: type, fsm: *FSMType, transitions: []const Transition(...))

Adds multiple transitions to an FSM in bulk.

Parameters:

  • FSMType - The state machine type
  • fsm - Pointer to the FSM instance
  • transitions - Slice of transition descriptors

Example:

try FSM.addTransitions(MyFSM, &fsm, &.{
    .{ .event = .start, .from = .idle, .to = .running },
    .{ .event = .stop, .from = .running, .to = .stopped },
});

Testing Helper Functions

applyEventsAndVerify(comptime FSMType: type, fsm: *FSMType, events: []const Event, expected_final_state: State)

Applies a sequence of events and verifies the final state.

Parameters:

  • FSMType - The state machine type
  • fsm - Pointer to the FSM instance
  • events - Slice of events to apply in order
  • expected_final_state - Expected state after all events

Example:

try FSM.applyEventsAndVerify(MyFSM, &fsm, &.{
    .start,
    .stop,
}, .stopped);

expectValidNextStates(comptime FSMType: type, fsm: *const FSMType, expected_states: []const State)

Verifies that specified states are reachable from the current state.

Parameters:

  • FSMType - The state machine type
  • fsm - Const pointer to the FSM instance
  • expected_states - Slice of states that should be valid next states

expectInvalidNextStates(comptime FSMType: type, fsm: *const FSMType, invalid_states: []const State)

Verifies that specified states are NOT reachable from the current state.

Parameters:

  • FSMType - The state machine type
  • fsm - Const pointer to the FSM instance
  • invalid_states - Slice of states that should NOT be valid next states

Builder Pattern

FSMBuilder(comptime FSMType: type)

Creates a builder for fluent FSM configuration.

Methods:

  • init() - Creates a new builder instance
  • withEvent(event: Event, from: State, to: State) !*Self - Adds a transition (chainable)
  • build() FSMType - Builds and returns the configured FSM

Example:

const Builder = FSM.FSMBuilder(MyFSM);

var builder = Builder.init();
_ = try builder.withEvent(.start, .idle, .running);
_ = try builder.withEvent(.stop, .running, .stopped);
_ = try builder.withEvent(.reset, .stopped, .idle);

var fsm = builder.build();
defer fsm.deinit();

Patterns

Pattern 1: FSM in before/after Hooks

pub const MyTests = struct {
    var fsm: MyFSM = undefined;

    test "tests:before" {
        Factory.resetSequences();
        fsm = MyFSM.init();
        try FSM.addTransitions(MyFSM, &fsm, &.{
            .{ .event = .start, .from = .idle, .to = .running },
            .{ .event = .stop, .from = .running, .to = .stopped },
        });
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "my test" {
        try fsm.do(.start);
        try expect.toBeTrue(fsm.isCurrently(.running));
    }
};

Pattern 2: Factory Functions for FSM Setup

fn createMenuFSM() !MenuFSM {
    var fsm = MenuFSM.init();
    errdefer fsm.deinit();

    try fsm.addEventAndTransition(.start_game, .main_menu, .loading);
    try fsm.addEventAndTransition(.open_settings, .main_menu, .settings);
    try fsm.addEventAndTransition(.back, .settings, .main_menu);

    return fsm;
}

pub const MenuTests = struct {
    var fsm: MenuFSM = undefined;

    test "tests:before" {
        fsm = createMenuFSM() catch unreachable;
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "navigation" {
        try fsm.do(.open_settings);
        try expect.toBeTrue(fsm.isCurrently(.settings));
    }
};

Pattern 3: Event Sequence Testing

test "complete navigation flow" {
    var fsm = createMenuFSM() catch unreachable;
    defer fsm.deinit();

    // Test complete flow with sequence
    try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
        .open_settings,
        .open_graphics,
        .back,
        .back,
    }, .main_menu);
}

Pattern 4: Builder Pattern for Complex FSMs

test "build complex FSM" {
    const Builder = FSM.FSMBuilder(GameFSM);

    var builder = Builder.init();

    // Menu states
    _ = try builder.withEvent(.start, .main_menu, .loading);
    _ = try builder.withEvent(.settings, .main_menu, .settings);

    // Gameplay states
    _ = try builder.withEvent(.pause, .gameplay, .paused);
    _ = try builder.withEvent(.resume, .paused, .gameplay);
    _ = try builder.withEvent(.quit, .paused, .main_menu);

    // Game over
    _ = try builder.withEvent(.game_over, .gameplay, .game_over);
    _ = try builder.withEvent(.restart, .game_over, .loading);

    var fsm = builder.build();
    defer fsm.deinit();

    // Test full game flow
    try FSM.applyEventsAndVerify(GameFSM, &fsm, &.{
        .start,
        .pause,
        .resume,
    }, .gameplay);
}

Pattern 5: State Validation Testing

pub const ValidationTests = struct {
    var fsm: MenuFSM = undefined;

    test "tests:before" {
        fsm = createMenuFSM() catch unreachable;
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "validate transitions from main menu" {
        // From main menu, can go to loading or settings
        try FSM.expectValidNextStates(MenuFSM, &fsm, &.{
            .loading,
            .settings,
        });

        // Cannot go directly to gameplay or paused
        try FSM.expectInvalidNextStates(MenuFSM, &fsm, &.{
            .gameplay,
            .paused,
        });
    }
};

Examples

Complete Game Menu FSM Test Suite

const std = @import("std");
const zspec = @import("zspec");
const zigfsm = @import("zigfsm");
const FSM = @import("zspec-fsm");
const expect = zspec.expect;
const Factory = zspec.Factory;

test {
    zspec.runAll(@This());
}

// Menu State Machine
const MenuState = enum {
    main_menu,
    settings,
    graphics_settings,
    audio_settings,
    gameplay,
    paused,
    game_over,
    loading,
};

const MenuEvent = enum {
    start_game,
    open_settings,
    open_graphics,
    open_audio,
    back,
    pause,
    resume,
    quit_to_menu,
    game_over,
    restart,
};

const MenuFSM = zigfsm.StateMachine(MenuState, MenuEvent, .main_menu);

fn createMenuFSM() !MenuFSM {
    var fsm = MenuFSM.init();
    errdefer fsm.deinit();

    try FSM.addTransitions(MenuFSM, &fsm, &.{
        // Main menu transitions
        .{ .event = .start_game, .from = .main_menu, .to = .loading },
        .{ .event = .open_settings, .from = .main_menu, .to = .settings },

        // Settings menu
        .{ .event = .open_graphics, .from = .settings, .to = .graphics_settings },
        .{ .event = .open_audio, .from = .settings, .to = .audio_settings },
        .{ .event = .back, .from = .settings, .to = .main_menu },

        // Sub-settings
        .{ .event = .back, .from = .graphics_settings, .to = .settings },
        .{ .event = .back, .from = .audio_settings, .to = .settings },

        // Gameplay
        .{ .event = .pause, .from = .gameplay, .to = .paused },
        .{ .event = .game_over, .from = .gameplay, .to = .game_over },

        // Paused
        .{ .event = .resume, .from = .paused, .to = .gameplay },
        .{ .event = .quit_to_menu, .from = .paused, .to = .main_menu },

        // Game over
        .{ .event = .restart, .from = .game_over, .to = .loading },
        .{ .event = .quit_to_menu, .from = .game_over, .to = .main_menu },
    });

    // Loading transition (no event needed)
    try fsm.addTransition(.loading, .gameplay);

    return fsm;
}

pub const MenuNavigationTests = struct {
    var fsm: MenuFSM = undefined;

    test "tests:before" {
        Factory.resetSequences();
        fsm = createMenuFSM() catch unreachable;
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "starts at main menu" {
        try expect.toBeTrue(fsm.isCurrently(.main_menu));
    }

    test "can navigate to settings" {
        try fsm.do(.open_settings);
        try expect.toBeTrue(fsm.isCurrently(.settings));
    }

    test "can navigate back from settings" {
        try fsm.do(.open_settings);
        try fsm.do(.back);
        try expect.toBeTrue(fsm.isCurrently(.main_menu));
    }

    test "complete settings navigation flow" {
        try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
            .open_settings,
            .open_graphics,
            .back,
            .open_audio,
            .back,
            .back,
        }, .main_menu);
    }
};

pub const GameplayTests = struct {
    var fsm: MenuFSM = undefined;

    test "tests:before" {
        fsm = createMenuFSM() catch unreachable;
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "can start game and pause" {
        try fsm.do(.start_game);
        try fsm.transitionTo(.gameplay);
        try fsm.do(.pause);
        try expect.toBeTrue(fsm.isCurrently(.paused));
    }

    test "complete game flow" {
        try fsm.do(.start_game);
        try fsm.transitionTo(.gameplay);
        try fsm.do(.game_over);
        try fsm.do(.restart);
        try expect.toBeTrue(fsm.isCurrently(.loading));
    }

    test "pause and resume" {
        try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
            .start_game,
        }, .loading);

        try fsm.transitionTo(.gameplay);

        try FSM.applyEventsAndVerify(MenuFSM, &fsm, &.{
            .pause,
            .resume,
        }, .gameplay);
    }
};

pub const StateValidationTests = struct {
    var fsm: MenuFSM = undefined;

    test "tests:before" {
        fsm = createMenuFSM() catch unreachable;
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "validate main menu transitions" {
        try FSM.expectValidNextStates(MenuFSM, &fsm, &.{
            .loading,
            .settings,
        });
    }

    test "validate settings transitions" {
        try fsm.do(.open_settings);
        try FSM.expectValidNextStates(MenuFSM, &fsm, &.{
            .graphics_settings,
            .audio_settings,
            .main_menu,
        });
    }
};

// Player State Machine
const PlayerState = enum { alive, invincible, dead };
const PlayerEvent = enum { take_damage, collect_powerup, die, respawn, powerup_expired };
const PlayerFSM = zigfsm.StateMachine(PlayerState, PlayerEvent, .alive);

pub const PlayerTests = struct {
    var fsm: PlayerFSM = undefined;

    test "tests:before" {
        fsm = PlayerFSM.init();
        try FSM.addTransitions(PlayerFSM, &fsm, &.{
            .{ .event = .take_damage, .from = .alive, .to = .alive },
            .{ .event = .die, .from = .alive, .to = .dead },
            .{ .event = .collect_powerup, .from = .alive, .to = .invincible },
            .{ .event = .powerup_expired, .from = .invincible, .to = .alive },
            .{ .event = .die, .from = .invincible, .to = .dead },
            .{ .event = .respawn, .from = .dead, .to = .alive },
        });
    }

    test "tests:after" {
        fsm.deinit();
    }

    test "death and respawn cycle" {
        try FSM.applyEventsAndVerify(PlayerFSM, &fsm, &.{
            .take_damage,
            .take_damage,
            .die,
            .respawn,
        }, .alive);
    }

    test "powerup flow" {
        try FSM.applyEventsAndVerify(PlayerFSM, &fsm, &.{
            .collect_powerup,
            .powerup_expired,
        }, .alive);
    }
};

Best Practices

  1. Use factory functions to create FSMs with common transition configurations
  2. Reset sequences in beforeAll/before hooks for test isolation
  3. Use bulk transitions with addTransitions() for cleaner setup
  4. Test event sequences with applyEventsAndVerify() to validate complete flows
  5. Validate state transitions with expectValidNextStates() and expectInvalidNextStates()
  6. Use builder pattern for complex FSMs with many transitions
  7. Always deinit FSMs in after hooks to prevent memory leaks
  8. Separate FSMs by concern - menu navigation, player state, connection state, etc.

See Also

Clone this wiki locally