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.