internal/testutil/statespace/statespace.go (73 lines of code) (raw):
package statespace
import (
"math/rand/v2"
"strings"
"testing"
)
// Model implements a simple fuzz-style test framework.
// Rather than fuzz scalar values like property-based tests, this framework enumerates the subsets of a bounded, high-cardinality state space.
// Essentially, the framework will call the subject with every subset of the known mutations and assert on each invariant.
// Application order of mutations is randomized to avoid order-dependence without fully permuting the space (the implementation is very fast).
type Model[State any, Result any] struct {
initial func() State
subject func(State) Result
transitions []mutation[State]
invariants []*invariant[State, Result]
}
type invariant[T any, TT any] struct {
Name string
Assert func(T, TT) bool
}
type mutation[T any] struct {
Name string
Func func(T) T
}
// Test creates a new model for testing the given subject.
func Test[T any, TT any](fn func(T) TT) *Model[T, TT] { return &Model[T, TT]{subject: fn} }
func (m *Model[T, TT]) WithInitialState(fn func() T) *Model[T, TT] {
m.initial = fn
return m
}
// WithMutation appends a function that will be applied to the state while evaluating the model.
func (m *Model[T, TT]) WithMutation(name string, fn func(T) T) *Model[T, TT] {
m.transitions = append(m.transitions, mutation[T]{Name: name, Func: fn})
return m
}
// WithInvariant appends a function that will be used to assert on the behavior of the subject for every subset of mutations.
func (m *Model[T, TT]) WithInvariant(name string, fn func(state T, result TT) bool) *Model[T, TT] {
m.invariants = append(m.invariants, &invariant[T, TT]{Name: name, Assert: fn})
return m
}
// Evaluate executes the test.
func (m *Model[T, TT]) Evaluate(t *testing.T) {
m.evaluate(t.Errorf)
}
func (m *Model[T, TT]) evaluate(fail func(msg string, args ...any)) {
var testCases [][]bool
for i := range 1 << len(m.transitions) {
stack := make([]bool, len(m.transitions))
for j := range m.transitions {
stack[j] = (i>>j)&1 == 1
}
testCases = append(testCases, stack)
}
rand.Shuffle(len(testCases), func(i, j int) { testCases[i], testCases[j] = testCases[j], testCases[i] })
for _, bitmap := range testCases {
var state T
if m.initial != nil {
state = m.initial()
}
// Build the state by applying mutations
for _, i := range rand.Perm(len(bitmap)) {
if bitmap[i] {
state = m.transitions[i].Func(state)
}
}
var stack []string
rand.Shuffle(len(m.invariants), func(i, j int) { m.invariants[i], m.invariants[j] = m.invariants[j], m.invariants[i] })
for _, inv := range m.invariants {
if inv.Assert(state, m.subject(state)) {
continue
}
// Defer building the stack strings to avoid allocating the memory for passing tests
if stack == nil {
for i, enabled := range bitmap {
if enabled {
stack = append(stack, m.transitions[i].Name)
}
}
}
fail("invariant '%s' failed with mutation stack: [%s]", inv.Name, strings.Join(stack, ", "))
}
}
}