Skip to content

Unit Testing Guide

This guide demonstrates how to write robust, deterministic unit tests for fsmc state machines using GoogleTest and Catch2.

Because fsmc state machines are stack-allocated, zero-heap, and side-effect isolated via the 4-domain datapath (InPorts, OutPorts, Registers, Services), unit testing follows the classic, clean Arrange-Act-Assert (AAA) pattern without requiring complex test fixtures.


1. GoogleTest Recipes

Complete Unit Test Suite

#include <gtest/gtest.h>
#include "fsm/backend/cpp/runtime/fsm.hpp"

// ----------------------------------------------------------------------------
// Domain Definitions Under Test
// ----------------------------------------------------------------------------
struct Idle    { static constexpr std::string_view name = "Idle";    };
struct Running { static constexpr std::string_view name = "Running"; };

struct EvStart {};
struct EvStop  {};

struct DroneInPorts   { float battery_percent = 100.0f; };
struct DroneOutPorts  { bool motor_enable = false;      };
struct DroneRegisters { std::uint32_t start_count = 0;  };

struct BatteryCheckGuard {
    bool operator()(const DroneInPorts& in) const noexcept {
        return in.battery_percent >= 20.0f;
    }
};

struct ArmAction {
    void operator()(DroneOutPorts& out, DroneRegisters& reg) const noexcept {
        out.motor_enable = true;
        reg.start_count += 1;
    }
};

using DroneTable = fsm::transition_table<
    fsm::row<Idle,    EvStart, Running>::when<BatteryCheckGuard>::then<ArmAction>,
    fsm::row<Running, EvStop,  Idle>
>;

using DroneFSM = fsm::fsm<DroneTable, DroneInPorts, DroneOutPorts, DroneRegisters>;

// ----------------------------------------------------------------------------
// Unit Tests
// ----------------------------------------------------------------------------

TEST(DroneFsmTest, InitialStateIsIdle) {
    // Arrange & Act
    DroneFSM sm;

    // Assert
    EXPECT_TRUE(sm.is_in<Idle>());
    EXPECT_EQ(sm.current_state_name(), "Idle");
    EXPECT_EQ(sm.registers().start_count, 0u);
}

TEST(DroneFsmTest, SuccessfulTransitionArmsMotorsAndIncrementsCounter) {
    // Arrange
    DroneFSM sm;
    DroneInPorts in{.battery_percent = 85.0f};
    DroneOutPorts out{};

    // Act
    fsm::dispatch_result res = sm.dispatch(EvStart{}, in, out);

    // Assert
    EXPECT_TRUE(res.is_success());
    EXPECT_TRUE(sm.is_in<Running>());
    EXPECT_TRUE(out.motor_enable);
    EXPECT_EQ(sm.registers().start_count, 1u);
}

TEST(DroneFsmTest, LowBatteryRejectsStartTransition) {
    // Arrange
    DroneFSM sm;
    DroneInPorts in{.battery_percent = 12.0f}; // Below 20% threshold
    DroneOutPorts out{};

    // Act
    fsm::dispatch_result res = sm.dispatch(EvStart{}, in, out);

    // Assert: Transition rejected by guard, state unchanged, no side effects
    EXPECT_TRUE(res.is_guard_rejected());
    EXPECT_FALSE(res.is_success());
    EXPECT_TRUE(sm.is_in<Idle>());
    EXPECT_FALSE(out.motor_enable);
    EXPECT_EQ(sm.registers().start_count, 0u);
}

TEST(DroneFsmTest, UnhandledEventIsReported) {
    // Arrange
    DroneFSM sm; // In Idle

    // Act: EvStop has no transition defined from Idle
    fsm::dispatch_result res = sm.dispatch(EvStop{});

    // Assert
    EXPECT_TRUE(res.is_unhandled());
    EXPECT_TRUE(sm.is_in<Idle>());
}

2. Catch2 Recipes

#include <catch2/catch_test_macros.hpp>
#include "fsm/backend/cpp/runtime/fsm.hpp"

TEST_CASE("Drone FSM State Transitions", "[fsm][drone]") {
    DroneFSM sm;
    DroneInPorts in{.battery_percent = 90.0f};
    DroneOutPorts out{};

    SECTION("Successful transition from Idle to Running") {
        auto res = sm.dispatch(EvStart{}, in, out);
        REQUIRE(res.is_success());
        REQUIRE(sm.is_in<Running>());
        REQUIRE(out.motor_enable == true);
        REQUIRE(sm.registers().start_count == 1u);
    }

    SECTION("Rejected transition on low battery") {
        in.battery_percent = 10.0f;
        auto res = sm.dispatch(EvStart{}, in, out);
        REQUIRE(res.is_guard_rejected());
        REQUIRE(sm.is_in<Idle>());
        REQUIRE(out.motor_enable == false);
        REQUIRE(sm.registers().start_count == 0u);
    }

    SECTION("Cycle tick step evaluation") {
        fsm::step_result step_res = sm.step(in, out);
        REQUIRE(step_res.is_steady());
    }
}

3. Mocking External Services

When state actions interact with hardware or external RPC through Services, you can inject mock service objects directly in tests:

struct IMotorHardware {
    virtual ~IMotorHardware() = default;
    virtual void write_pwm(uint16_t channel, float duty) = 0;
};

struct DroneServices {
    IMotorHardware* hardware = nullptr;
};

// In Unit Test:
class MockMotorHardware : public IMotorHardware {
public:
    int write_count = 0;
    float last_duty = 0.0f;
    void write_pwm(uint16_t, float duty) override {
        write_count++;
        last_duty = duty;
    }
};

TEST(DroneServiceTest, ActionCallsServiceDriver) {
    MockMotorHardware mock_hw;
    DroneServices srv{&mock_hw};

    DroneFSM sm(DroneRegisters{}, srv);
    DroneInPorts in{.battery_percent = 100.0f};
    DroneOutPorts out{};

    sm.dispatch(EvStart{}, in, out);
    // Verify hardware mock interaction
}

---

## 4. Testing Multi-Threaded State Machines

When unit testing `fsm::thread_safe_fsm`, remember that direct access via `registers()` is prohibited. Always use `snapshot_registers()` to inspect outcomes deterministically:

```cpp
#include "fsm/backend/cpp/runtime/thread_safe_fsm.hpp"

TEST(AsyncDroneTest, AsyncDispatchAndSnapshotInspection) {
    // Arrange using policy instantiation:
    using AsyncDroneFSM = fsm::make_thread_safe_fsm<
        DroneTable,
        fsm::with_registers<DroneRegisters>
    >;

    AsyncDroneFSM async_sm;
    async_sm.start_worker();

    // Act: post event asynchronously
    std::future<fsm::dispatch_result> fut = async_sm.post_async(EvStart{});
    fsm::dispatch_result res = fut.get();

    // Assert
    EXPECT_TRUE(res.is_success());
    EXPECT_TRUE(async_sm.is_in_state<Running>());

    // Safe-by-Design datapath inspection (no data races):
    DroneRegisters snapshot = async_sm.snapshot_registers();
    EXPECT_EQ(snapshot.start_count, 1u);

    async_sm.stop_worker();
}

5. Testing with the Embedded Blackbox Flight Recorder

Unit tests can assert transition sequences, historical states, and event execution order using with_trace_buffer<N>:

#include "fsm/backend/cpp/runtime/fsm.hpp"
#include <gtest/gtest.h>

TEST(FlightRecorderTest, VerifyExecutionTraceSequence) {
    using TestFSM = fsm::make_fsm<
        DroneTable,
        fsm::with_registers<DroneRegisters>,
        fsm::with_trace_buffer<16>
    >;

    DroneRegisters reg{};
    TestFSM sm(reg);

    DroneInPorts in{};
    DroneOutPorts out{};

    sm.dispatch(EvStart{}, in, out);
    sm.dispatch(EvLand{}, in, out);

    const auto& recorder = sm.observer().recorder();
    ASSERT_EQ(recorder.size(), 2u);

    // Verify chronological sequence
    EXPECT_EQ(recorder[0].source_state, "Idle");
    EXPECT_EQ(recorder[0].event_name, "EvStart");
    EXPECT_EQ(recorder[0].target_state, "Hovering");

    EXPECT_EQ(recorder[1].source_state, "Hovering");
    EXPECT_EQ(recorder[1].event_name, "EvLand");
    EXPECT_EQ(recorder[1].target_state, "Landed");
}

6. Automated MC/DC Safety Test Synthesis

For DO-178C and ISO 26262 compliance testing, fsmc can automatically synthesize complete GoogleTest test harnesses verifying Modified Condition / Decision Coverage (MC/DC) for all transition guards:

fsmc -i drone.sysml --emit-test-harness test_drone_mcdc.cpp
g++ -std=c++20 test_drone_mcdc.cpp -lgtest -lgtest_main -pthread -o mcdc_tests
./mcdc_tests

For complete details on condition independence pairs and truth table derivation, see the MC/DC Test Synthesis Guide.