Testing & Proptest Test Harness
Comprehensive test methodologies for statecharts: simulated virtual clocks, path walking, transition coverage, and property-based fuzzing with proptest.
🎯 Architecture: Testing Dimensions
flowchart TD
M[Statechart JSON / Macro] --> V[Virtual Simulated Clocks: Test 24-hour delays in 1 ms]
M --> P[Path Walking: Generate Every Feasible Transition Sequence]
M --> C[Coverage Analyzer: Assert 100% State & Transition Coverage]
M --> Prop[Property-Based Testing: Invariant Fuzzing with proptest]
xstate-statemachine provides a dedicated test crate (xstate-testing) and testing utilities designed to make statechart validation deterministic, fast, and exhaustive.
📦 Dependencies
[dev-dependencies]
xstate-statemachine = { version = "0.1", features = ["testing"] }
proptest = "1.6"
⏱️ Virtual Simulated Clocks (SimulatedClock)
Never use std::thread::sleep or tokio::time::sleep in unit tests. With SimulatedClock, you advance simulated time deterministically:
#![allow(unused)]
fn main() {
use std::time::Duration;
use xstate_statemachine::core::clock::SimulatedClock;
use xstate_statemachine::prelude::*;
#[test]
fn test_timer_expiration_in_one_millisecond() -> Result<(), Box<dyn std::error::Error>> {
let machine = Machine::from_json(r#"{
"id": "door",
"initial": "opened",
"states": {
"opened": {
"after": {
"30000": "closed"
}
},
"closed": {}
}
}"#)?;
let clock = SimulatedClock::new();
let mut interp = SyncInterpreter::new_with_clock(
machine,
MachineLogic::dynamic(),
clock.clone()
);
interp.start()?;
assert_eq!(interp.state_ids(), ["door.opened"]);
// Advance virtual clock by 30 seconds instantly!
clock.advance(Duration::from_secs(30));
interp.tick()?;
assert_eq!(interp.state_ids(), ["door.closed"]);
Ok(())
}
}
🚶 Path Walking & State Coverage
Generate test walks that visit every valid state and transition in your machine:
#![allow(unused)]
fn main() {
use xstate_statemachine::prelude::*;
use xstate_testing::{shortest_paths, assert_coverage};
#[test]
fn test_exhaustive_path_coverage() -> Result<(), Box<dyn std::error::Error>> {
let machine = Machine::from_json(ORDER_JSON)?;
let paths = shortest_paths(&machine)?;
for path in paths {
let mut interp = SyncInterpreter::new(machine.clone(), MachineLogic::dynamic());
interp.start()?;
for step in path.steps {
interp.send(step.event)?;
assert_eq!(interp.state_ids(), step.expected_states);
}
}
Ok(())
}
}
🎲 Property-Based Invariant Fuzzing (proptest)
Verify that no sequence of random events can ever violate core business invariants (e.g. negative balances, deadlocks, or illegal state configurations):
#![allow(unused)]
fn main() {
use proptest::prelude::*;
use xstate_statemachine::prelude::*;
proptest! {
#[test]
fn machine_never_reaches_illegal_state(
events in proptest::collection::vec(
prop_oneof![
Just("CLICK"),
Just("RESET"),
Just("SUBMIT"),
Just("CANCEL"),
Just("RANDOM_EVENT"),
],
1..100
)
) {
let machine = Machine::from_json(MY_MACHINE).unwrap();
let mut interp = SyncInterpreter::new(machine, MachineLogic::dynamic());
interp.start().unwrap();
for event in events {
let _ = interp.send(event);
// Invariant: If machine is stopped, it must be in a terminal state
if interp.is_done() {
assert!(interp.state_ids().contains(&"terminal".to_string()));
}
}
}
}
}