fast-check Model-Based Testing: Verify Stateful JavaScript with Commands
Property-based testing with fast-check generates random inputs for pure functions. But JavaScript applications have state — React components maintain it, Redux stores track it, caches hold it, queues process it. Model-based testing generates random sequences of operations and verifies that your system stays consistent with a simple reference model.
fast-check implements model-based testing via the commands runner, which works with both synchronous and asynchronous systems.
Core Concept
Model-based testing maintains two implementations in parallel:
- The model — a simple reference implementation (usually a plain object or Map) that you trust
- The real system — your actual implementation under test
fast-check generates random sequences of commands (operations). Each command:
- Checks a precondition (is this operation valid in the current state?)
- Runs against both model and real system
- Asserts the results match
When real system and model diverge, you've found a bug.
Synchronous Model-Based Testing
Testing a custom stack implementation:
const fc = require('fast-check');
// The real system under test
class BoundedStack {
constructor(capacity) {
this.capacity = capacity;
this._items = [];
}
push(item) {
if (this._items.length >= this.capacity) throw new Error('Stack full');
this._items.push(item);
}
pop() {
if (this._items.length === 0) throw new Error('Stack empty');
return this._items.pop();
}
peek() {
if (this._items.length === 0) return undefined;
return this._items[this._items.length - 1];
}
get size() { return this._items.length; }
get isEmpty() { return this._items.length === 0; }
get isFull() { return this._items.length >= this.capacity; }
}
// Model (simple reference implementation)
class StackModel {
constructor(capacity) {
this.capacity = capacity;
this.items = [];
}
}
// Commands
class PushCommand {
constructor(value) {
this.value = value;
}
check(model) {
return model.items.length < model.capacity;
}
run(model, real) {
model.items.push(this.value);
real.push(this.value);
expect(real.size).toBe(model.items.length);
expect(real.peek()).toBe(this.value);
expect(real.isEmpty).toBe(false);
}
toString() {
return `Push(${this.value})`;
}
}
class PopCommand {
check(model) {
return model.items.length > 0;
}
run(model, real) {
const expectedTop = model.items[model.items.length - 1];
model.items.pop();
const actual = real.pop();
expect(actual).toBe(expectedTop);
expect(real.size).toBe(model.items.length);
}
toString() {
return 'Pop';
}
}
class PeekCommand {
check(_model) {
return true;
}
run(model, real) {
const expected = model.items[model.items.length - 1];
const actual = real.peek();
expect(actual).toBe(expected);
expect(real.size).toBe(model.items.length); // peek doesn't change size
}
toString() {
return 'Peek';
}
}
// The test
test('BoundedStack model-based test', () => {
const capacity = 5;
const allCommands = [
fc.integer({ min: -100, max: 100 }).map(v => new PushCommand(v)),
fc.constant(new PopCommand()),
fc.constant(new PeekCommand()),
];
fc.assert(
fc.property(
fc.commands(allCommands, { maxCommands: 50 }),
(cmds) => {
const model = new StackModel(capacity);
const real = new BoundedStack(capacity);
fc.modelRun(
() => ({ model, real }),
cmds
);
}
),
{ numRuns: 500 }
);
});Async Commands
For testing async systems (HTTP APIs, databases, async queues):
const fc = require('fast-check');
class TodoModel {
constructor() {
this.todos = new Map(); // id -> { title, completed }
this.nextId = 1;
}
}
class CreateTodoCommand {
constructor(title) {
this.title = title;
}
check(_model) {
return true; // Can always create
}
async run(model, client) {
const response = await client.post('/api/todos', { title: this.title });
expect(response.status).toBe(201);
const todo = response.data;
model.todos.set(todo.id, { title: this.title, completed: false });
expect(todo.title).toBe(this.title);
expect(todo.completed).toBe(false);
expect(todo.id).toBeTruthy();
}
toString() {
return `CreateTodo("${this.title}")`;
}
}
class CompleteTodoCommand {
constructor(idSelector) {
this.idSelector = idSelector;
}
check(model) {
return [...model.todos.values()].some(t => !t.completed);
}
async run(model, client) {
const incompleteIds = [...model.todos.entries()]
.filter(([, todo]) => !todo.completed)
.map(([id]) => id);
const todoId = incompleteIds[this.idSelector % incompleteIds.length];
const response = await client.patch(`/api/todos/${todoId}`, { completed: true });
expect(response.status).toBe(200);
model.todos.get(todoId).completed = true;
}
toString() {
return 'CompleteTodo';
}
}
class ListTodosCommand {
check(_model) {
return true;
}
async run(model, client) {
const response = await client.get('/api/todos');
expect(response.status).toBe(200);
const apiTodos = response.data;
expect(apiTodos).toHaveLength(model.todos.size);
for (const [id, expected] of model.todos) {
const apiTodo = apiTodos.find(t => t.id === id);
expect(apiTodo).toBeDefined();
expect(apiTodo.completed).toBe(expected.completed);
}
}
toString() {
return 'ListTodos';
}
}
test('Todo API model-based test', async () => {
const client = axios.create({ baseURL: 'http://localhost:3000' });
await client.delete('/api/todos/all'); // Clean state before test
const allCommands = [
fc.string({ minLength: 1, maxLength: 50 }).map(t => new CreateTodoCommand(t)),
fc.integer({ min: 0, max: 100 }).map(i => new CompleteTodoCommand(i)),
fc.constant(new ListTodosCommand()),
];
await fc.assert(
fc.asyncProperty(
fc.commands(allCommands, { maxCommands: 20 }),
async (cmds) => {
const model = new TodoModel();
await fc.asyncModelRun(
() => ({ model, real: client }),
cmds
);
}
),
{ numRuns: 50 }
);
await client.delete('/api/todos/all'); // Clean state after test
});Testing Redux Reducers
Redux reducers are pure functions — perfect for model-based testing:
const fc = require('fast-check');
const { cartReducer, addItem, removeItem, updateQuantity, clearCart } = require('./cartSlice');
// Model mirrors the reducer logic, simplified
class CartModel {
constructor() {
this.items = new Map(); // productId -> { quantity, price }
}
addItem(productId, price) {
const existing = this.items.get(productId);
if (existing) {
this.items.set(productId, { ...existing, quantity: existing.quantity + 1 });
} else {
this.items.set(productId, { quantity: 1, price });
}
}
removeItem(productId) {
this.items.delete(productId);
}
get totalItems() {
return [...this.items.values()].reduce((sum, item) => sum + item.quantity, 0);
}
get totalPrice() {
return [...this.items.values()].reduce(
(sum, item) => sum + item.quantity * item.price,
0
);
}
}
// Commands as simple objects (synchronous)
const addItemCommand = (productId, price) => ({
check: () => true,
run: (model, state) => {
model.addItem(productId, price);
const action = addItem({ id: productId, price, name: `Product ${productId}` });
const newState = cartReducer(state, action);
expect(newState.items.find(i => i.id === productId)).toBeDefined();
expect(newState.totalItems).toBe(model.totalItems);
return newState;
},
toString: () => `AddItem(${productId}, ${price})`
});
const removeItemCommand = (idSelector) => ({
check: (model) => model.items.size > 0,
run: (model, state) => {
const productIds = [...model.items.keys()];
const productId = productIds[idSelector % productIds.length];
model.removeItem(productId);
const newState = cartReducer(state, removeItem(productId));
expect(newState.items.find(i => i.id === productId)).toBeUndefined();
expect(newState.totalItems).toBe(model.totalItems);
return newState;
},
toString: () => 'RemoveItem'
});
// Adapting to fast-check's Command interface
class CommandAdapter {
constructor(cmd, getNextState) {
this._cmd = cmd;
this._getNextState = getNextState;
}
check(model) { return this._cmd.check(model); }
run(model, real) {
const newState = this._cmd.run(model, real.state);
real.state = newState;
}
toString() { return this._cmd.toString(); }
}
test('Cart reducer model-based test', () => {
const productIds = ['prod_1', 'prod_2', 'prod_3', 'prod_4'];
const prices = [9.99, 19.99, 49.99, 99.99];
const allCommands = [
fc.tuple(
fc.integer({ min: 0, max: productIds.length - 1 }),
fc.integer({ min: 0, max: prices.length - 1 })
).map(([pi, pi2]) =>
new CommandAdapter(addItemCommand(productIds[pi], prices[pi2]))
),
fc.integer({ min: 0, max: 100 }).map(i =>
new CommandAdapter(removeItemCommand(i))
),
];
fc.assert(
fc.property(
fc.commands(allCommands, { maxCommands: 30 }),
(cmds) => {
const model = new CartModel();
const real = { state: cartReducer(undefined, { type: '@@INIT' }) };
fc.modelRun(() => ({ model, real }), cmds);
// Final invariants
expect(real.state.totalItems).toBe(model.totalItems);
expect(real.state.totalPrice).toBeCloseTo(model.totalPrice, 2);
}
)
);
});Reading Failure Output
When a command sequence fails, fast-check shows the shrunk sequence:
Property failed after 3 tests
{ seed: 42, path: "2:0:1", endOnFailure: true }
Counterexample: [
CreateTodo("a"),
CompleteTodo,
CompleteTodo // <-- This fails: completing already-completed todo doesn't error
]
Shrunk 5 times (largest shrunk was CreateTodo("xyzabc..."), CompleteRandomTodo, CompleteTodo)The shrunk counterexample tells you: "create one todo, complete it, then try to complete it again." Your system under test either errors or silently completes an already-completed todo — and your model doesn't match.
Configuring the Commands Runner
fc.commands(allCommands, {
maxCommands: 50, // maximum sequence length
disableReplayLog: false, // include seed for replay on failure
replayPath: null, // replay a specific path from failure output
})
fc.assert(property, {
numRuns: 100, // number of sequences to generate
seed: 42, // fixed seed for reproducibility
verbose: true, // log each command execution
})When Model-Based Testing Pays Off
Use it when:
- Your system has meaningful state that persists across operations
- Operations have preconditions (can only withdraw if balance > 0)
- The order of operations matters for correctness
- You're testing a cache, queue, rate limiter, session system, or CRDT
It's overkill for pure functions with no internal state. For those, standard fc.property with random inputs is simpler and faster.
The biggest wins are in finding bugs that only appear after specific sequences — like a cache that returns stale data after an update followed by two reads, or a database that counts rows incorrectly after concurrent inserts and deletes.