This post is part of the Model-Based Stateful Testing with madhouse-rs series.
Imagine trying to test a distributed system with dozens of operations:
Traditional unit tests can’t capture the chaotic, interleaved nature of these scenarios.
Model-based testing offers a solution: define all possible operations, let the framework generate random sequences, and check that your system behaves correctly. But… how you structure those operations determines whether your test harness scales from 5 commands to 500.
proptest-state-machine sits firmly on the operations-open, data-closed side of the expression problem. You define a central Transition enum that lists every possible operation.
Let’s explore this with a simple counter example first—while the real-world blockchain scenarios would be more complex to demonstrate initially, the core design patterns are identical. We’ll see the blockchain applications later in the series.
use proptest_state_machine::*;
use proptest::prelude::*;
// The model state.
#[derive(Clone, Debug)]
struct CounterModel {
value: i32,
max_value: i32,
}
// The real system under test.
struct Counter {
value: i32,
max_value: i32,
}
// All possible operations in one enum.
#[derive(Clone, Debug)]
enum CounterTransition {
Inc,
Dec,
Reset,
}
impl StateMachine for CounterModel {
type State = CounterModel;
type Sut = Counter;
type Transition = CounterTransition;
fn init_state() -> BoxedStrategy<Self::State> {
Just(CounterModel { value: 0, max_value: 100 }).boxed()
}
fn init_sut(state: &Self::State) -> BoxedStrategy<Self::Sut> {
Just(Counter {
value: state.value,
max_value: state.max_value
}).boxed()
}
fn transitions(_state: &Self::State) -> BoxedStrategy<Self::Transition> {
prop_oneof![
Just(CounterTransition::Inc),
Just(CounterTransition::Dec),
Just(CounterTransition::Reset),
].boxed()
}
fn apply(
mut state: Self::State,
sut: &mut Self::Sut,
transition: Self::Transition,
) -> Self::State {
match transition {
CounterTransition::Inc => {
if state.value < state.max_value {
state.value += 1;
sut.value += 1;
}
}
CounterTransition::Dec => {
state.value -= 1;
sut.value -= 1;
}
CounterTransition::Reset => {
state.value = 0;
sut.value = 0;
}
}
state
}
fn postconditions(state: &Self::State, sut: &Self::Sut) {
assert_eq!(state.value, sut.value);
assert_eq!(state.max_value, sut.max_value);
assert!(state.value <= state.max_value);
}
}
This approach starts simple, but every new operation requires:
CounterTransition enum.apply function with a new match arm.transitions function to include the new operation.With 10 operations, this is manageable. With 100+ operations—like testing a blockchain node—it becomes unwieldy. The apply function grows into a monolithic match statement. Every developer adding a test command must touch this central file.
Consider testing the Stacks blockchain, where operations include:
enum StacksTransition {
MineBitcoinBlock,
MineStacksBlock(BlockData),
SubmitTransaction(Transaction),
ConnectPeer(PeerInfo),
DisconnectPeer(PeerId),
NetworkPartition(Vec<NodeId>),
RestoreNetwork,
RestartNode(NodeId),
// ... 50+ more operations.
}
The apply function becomes hundreds of lines. Adding new test scenarios requires editing this central bottleneck. Worse, the logic for each command is scattered—generation in transitions, preconditions mixed into apply, and postconditions in a separate function.
This scaling limitation becomes apparent in complex scenarios like reproducing a Stacks mainnet bug. While that specific attempt used hand-written tests, the enum-based approach would face the same bottleneck—dozens of blockchain operations in a central enum and apply function, making it difficult to generate the chaotic scenarios needed to reveal subtle consensus issues.