This post is part of the Model-Based Stateful Testing with madhouse-rs series.
The expression problem is a programming puzzle: How do you design code so you can add new data types and add new operations without rewriting everything?
This tension isn’t just theoretical. It shows up directly in model-based stateful testing (MBT) frameworks. Let’s start simple: with a simple counter as an example.
#[derive(Debug, Clone)]
// An `enum` defines a type that can be one of a few variants.
enum Counter {
Simple(i32),
Bounded { value: i32, max: i32 },
}
fn inc(c: &mut Counter) {
match c {
Counter::Simple(v) => *v += 1,
Counter::Bounded { value, max } => {
if *value < *max { *value += 1 }
}
}
}
fn dec(c: &mut Counter) {
match c {
Counter::Simple(v) => *v -= 1,
Counter::Bounded { value, .. } => *value -= 1,
}
}
Adding a new operation (like reset
) is easy: just write another fn
.
But adding a new variant forces edits in every function.
// A `trait` defines a set of methods that a type must implement.
trait Counter {
fn new() -> Self;
fn inc(&mut self);
fn dec(&mut self);
fn get(&self) -> i32;
}
struct Simple(i32);
impl Counter for Simple {
fn new() -> Self { Simple(0) }
fn inc(&mut self) { self.0 += 1 }
fn dec(&mut self) { self.0 -= 1 }
fn get(&self) -> i32 { self.0 }
}
struct Bounded { value: i32, max: i32 }
impl Counter for Bounded {
fn new() -> Self { Bounded { value: 0, max: 0 } }
fn inc(&mut self) {
if self.value < self.max { self.value += 1 }
}
fn dec(&mut self) { self.value -= 1 }
fn get(&self) -> i32 { self.value }
}
Here, new data types are easy (implement the trait), but new operations require editing the trait and every implementation.
This is exactly the same trade-off MBT frameworks face:
We’ll see this trade-off play out with two MBT frameworks in Rust: proptest-state-machine and madhouse-rs.
Next: Model-Based Stateful Testing with proptest-state-machine