by Pramod
Generate comprehensive test cases from state machine models covering all states, transitions, guard conditions, and invalid transition attempts for workflow-heavy features
npx @qaskills/cli add state-machine-test-generatorAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
You are an expert QA engineer specializing in state machine testing and workflow verification. When the user asks you to create, review, or improve state machine tests, follow these detailed instructions to generate comprehensive test suites that verify all states, transitions, guard conditions, entry/exit actions, and invalid transition rejection for workflow-driven features.
tests/
state-machines/
models/
order-machine.ts
payment-machine.ts
auth-machine.ts
generic-machine.ts
generators/
test-path-generator.ts
transition-table-generator.ts
invalid-transition-generator.ts
helpers/
machine-test-runner.ts
state-assertions.ts
action-spy.ts
tests/
order-lifecycle.test.ts
payment-flow.test.ts
auth-flow.test.ts
all-states-coverage.test.ts
all-transitions-coverage.test.ts
guard-conditions.test.ts
invalid-transitions.test.ts
nested-states.test.ts
parallel-states.test.ts
entry-exit-actions.test.ts
coverage/
state-coverage-reporter.ts
transition-matrix.ts
config/
machine-test.config.ts
Before generating tests, define the machine model. This model is the single source of truth for all test generation.
// order-machine.ts
import { createMachine, assign } from 'xstate';
interface OrderContext {
orderId: string;
items: Array<{ id: string; quantity: number; price: number }>;
total: number;
paymentId?: string;
shippingId?: string;
cancellationReason?: string;
refundAmount?: number;
retryCount: number;
}
type OrderEvent =
| { type: 'ADD_ITEM'; item: { id: string; quantity: number; price: number } }
| { type: 'REMOVE_ITEM'; itemId: string }
| { type: 'SUBMIT' }
| { type: 'PAYMENT_RECEIVED'; paymentId: string }
| { type: 'PAYMENT_FAILED'; reason: string }
| { type: 'RETRY_PAYMENT' }
| { type: 'SHIP'; shippingId: string }
| { type: 'DELIVER' }
| { type: 'CANCEL'; reason: string }
| { type: 'REQUEST_RETURN' }
| { type: 'APPROVE_RETURN' }
| { type: 'REJECT_RETURN' }
| { type: 'REFUND_PROCESSED'; amount: number };
const orderMachine = createMachine({
id: 'order',
initial: 'draft',
context: {
orderId: '',
items: [],
total: 0,
retryCount: 0,
} as OrderContext,
states: {
draft: {
on: {
ADD_ITEM: {
actions: assign({
items: ({ context, event }) => [...context.items, event.item],
total: ({ context, event }) => context.total + event.item.price * event.item.quantity,
}),
},
REMOVE_ITEM: {
actions: assign({
items: ({ context, event }) => context.items.filter(i => i.id !== event.itemId),
total: ({ context, event }) => {
const item = context.items.find(i => i.id === event.itemId);
return item ? context.total - item.price * item.quantity : context.total;
},
}),
},
SUBMIT: {
target: 'pending_payment',
guard: ({ context }) => context.items.length > 0,
},
CANCEL: 'cancelled',
},
},
pending_payment: {
on: {
PAYMENT_RECEIVED: {
target: 'confirmed',
actions: assign({ paymentId: ({ event }) => event.paymentId }),
},
PAYMENT_FAILED: [
{
target: 'payment_retry',
guard: ({ context }) => context.retryCount < 3,
},
{
target: 'cancelled',
},
],
CANCEL: {
target: 'cancelled',
actions: assign({ cancellationReason: ({ event }) => event.reason }),
},
},
},
payment_retry: {
entry: assign({ retryCount: ({ context }) => context.retryCount + 1 }),
on: {
RETRY_PAYMENT: 'pending_payment',
CANCEL: 'cancelled',
},
},
confirmed: {
on: {
SHIP: {
target: 'shipped',
actions: assign({ shippingId: ({ event }) => event.shippingId }),
},
CANCEL: {
target: 'refunding',
actions: assign({ cancellationReason: ({ event }) => event.reason }),
},
},
},
shipped: {
on: {
DELIVER: 'delivered',
},
},
delivered: {
on: {
REQUEST_RETURN: 'return_requested',
},
},
return_requested: {
on: {
APPROVE_RETURN: 'refunding',
REJECT_RETURN: 'delivered',
},
},
refunding: {
on: {
REFUND_PROCESSED: {
target: 'refunded',
actions: assign({ refundAmount: ({ event }) => event.amount }),
},
},
},
refunded: {
type: 'final',
},
cancelled: {
type: 'final',
},
},
});
// Export the machine definition for test generation
export { orderMachine, OrderContext, OrderEvent };
Generating test paths systematically ensures coverage rather than relying on manual enumeration.
// test-path-generator.ts
interface StateNode {
name: string;
transitions: Transition[];
isFinal: boolean;
}
interface Transition {
event: string;
target: string;
guard?: string;
actions?: string[];
}
interface TestPath {
name: string;
steps: TestStep[];
startState: string;
endState: string;
coversStates: string[];
coversTransitions: string[];
}
interface TestStep {
fromState: string;
event: string;
toState: string;
guard?: string;
expectedActions?: string[];
}
class TestPathGenerator {
private stateGraph: Map<string, StateNode> = new Map();
private allStates: Set<string> = new Set();
private allTransitions: Set<string> = new Set();
constructor(machineDefinition: any) {
this.parseMachineDefinition(machineDefinition);
}
private parseMachineDefinition(definition: any): void {
for (const [stateName, stateConfig] of Object.entries(definition.states)) {
this.allStates.add(stateName);
const transitions: Transition[] = [];
if ((stateConfig as any).on) {
for (const [event, target] of Object.entries((stateConfig as any).on)) {
const targetState = typeof target === 'string' ? target : (target as any).target;
if (targetState) {
transitions.push({
event,
target: targetState,
guard: typeof target === 'object' ? (target as any).guard?.toString() : undefined,
});
this.allTransitions.add(`${stateName}--${event}-->${targetState}`);
}
}
}
this.stateGraph.set(stateName, {
name: stateName,
transitions,
isFinal: (stateConfig as any).type === 'final',
});
}
}
generateAllStatesCoverage(initialState: string): TestPath[] {
const paths: TestPath[] = [];
const uncoveredStates = new Set(this.allStates);
// BFS to find shortest paths to each state
const queue: Array<{ state: string; path: TestStep[] }> = [
{ state: initialState, path: [] },
];
const visited = new Set<string>();
while (queue.length > 0 && uncoveredStates.size > 0) {
const { state, path } = queue.shift()!;
if (visited.has(state)) continue;
visited.add(state);
if (uncoveredStates.has(state)) {
uncoveredStates.delete(state);
paths.push({
name: `reach_${state}`,
steps: [...path],
startState: initialState,
endState: state,
coversStates: path.map(s => s.toState).concat(state),
coversTransitions: path.map(s => `${s.fromState}--${s.event}-->${s.toState}`),
});
}
const node = this.stateGraph.get(state);
if (node && !node.isFinal) {
for (const transition of node.transitions) {
queue.push({
state: transition.target,
path: [...path, {
fromState: state,
event: transition.event,
toState: transition.target,
guard: transition.guard,
}],
});
}
}
}
return paths;
}
generateAllTransitionsCoverage(initialState: string): TestPath[] {
const paths: TestPath[] = [];
const uncoveredTransitions = new Set(this.allTransitions);
// For each uncovered transition, find the shortest path that includes it
for (const transition of uncoveredTransitions) {
const [fromState, rest] = transition.split('-->');
const [source, event] = fromState.split('--');
// Find path from initial to the source state
const pathToSource = this.findShortestPath(initialState, source);
if (pathToSource) {
const fullPath = [...pathToSource, {
fromState: source,
event,
toState: rest,
}];
paths.push({
name: `transition_${source}_${event}_${rest}`,
steps: fullPath,
startState: initialState,
endState: rest,
coversStates: fullPath.map(s => s.toState),
coversTransitions: fullPath.map(s => `${s.fromState}--${s.event}-->${s.toState}`),
});
}
}
return paths;
}
generateInvalidTransitions(): Array<{ state: string; event: string }> {
const invalidTransitions: Array<{ state: string; event: string }> = [];
const allEvents = new Set<string>();
// Collect all events from the machine
for (const node of this.stateGraph.values()) {
for (const t of node.transitions) {
allEvents.add(t.event);
}
}
// For each state, find events that are NOT valid
for (const [stateName, node] of this.stateGraph) {
if (node.isFinal) continue;
const validEvents = new Set(node.transitions.map(t => t.event));
for (const event of allEvents) {
if (!validEvents.has(event)) {
invalidTransitions.push({ state: stateName, event });
}
}
}
return invalidTransitions;
}
private findShortestPath(from: string, to: string): TestStep[] | null {
if (from === to) return [];
const queue: Array<{ state: string; path: TestStep[] }> = [
{ state: from, path: [] },
];
const visited = new Set<string>();
while (queue.length > 0) {
const { state, path } = queue.shift()!;
if (visited.has(state)) continue;
visited.add(state);
const node = this.stateGraph.get(state);
if (!node) continue;
for (const transition of node.transitions) {
const newPath = [...path, {
fromState: state,
event: transition.event,
toState: transition.target,
}];
if (transition.target === to) return newPath;
queue.push({ state: transition.target, path: newPath });
}
}
return null;
}
getCoverageReport(executedTransitions: Set<string>): {
stateCoverage: number;
transitionCoverage: number;
uncoveredStates: string[];
uncoveredTransitions: string[];
} {
const coveredStates = new Set<string>();
for (const t of executedTransitions) {
const parts = t.split('-->');
const source = parts[0].split('--')[0];
coveredStates.add(source);
coveredStates.add(parts[1]);
}
return {
stateCoverage: Math.round((coveredStates.size / this.allStates.size) * 100),
transitionCoverage: Math.round((executedTransitions.size / this.allTransitions.size) * 100),
uncoveredStates: [...this.allStates].filter(s => !coveredStates.has(s)),
uncoveredTransitions: [...this.allTransitions].filter(t => !executedTransitions.has(t)),
};
}
}
// all-states-coverage.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { createActor } from 'xstate';
import { orderMachine } from '../models/order-machine';
describe('Order Machine -- All States Coverage', () => {
it('should reach the draft state (initial)', () => {
const actor = createActor(orderMachine).start();
expect(actor.getSnapshot().value).toBe('draft');
actor.stop();
});
it('should reach pending_payment from draft', () => {
const actor = createActor(orderMachine, {
input: { orderId: 'o1', items: [], total: 0, retryCount: 0 },
}).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
expect(actor.getSnapshot().value).toBe('pending_payment');
actor.stop();
});
it('should reach confirmed after payment', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_123' });
expect(actor.getSnapshot().value).toBe('confirmed');
actor.stop();
});
it('should reach payment_retry on payment failure', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_FAILED', reason: 'insufficient_funds' });
expect(actor.getSnapshot().value).toBe('payment_retry');
actor.stop();
});
it('should reach shipped after confirmation', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_123' });
actor.send({ type: 'SHIP', shippingId: 'ship_456' });
expect(actor.getSnapshot().value).toBe('shipped');
actor.stop();
});
it('should reach delivered after shipping', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_123' });
actor.send({ type: 'SHIP', shippingId: 'ship_456' });
actor.send({ type: 'DELIVER' });
expect(actor.getSnapshot().value).toBe('delivered');
actor.stop();
});
it('should reach return_requested from delivered', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_123' });
actor.send({ type: 'SHIP', shippingId: 'ship_456' });
actor.send({ type: 'DELIVER' });
actor.send({ type: 'REQUEST_RETURN' });
expect(actor.getSnapshot().value).toBe('return_requested');
actor.stop();
});
it('should reach refunding from return approval', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_123' });
actor.send({ type: 'SHIP', shippingId: 'ship_456' });
actor.send({ type: 'DELIVER' });
actor.send({ type: 'REQUEST_RETURN' });
actor.send({ type: 'APPROVE_RETURN' });
expect(actor.getSnapshot().value).toBe('refunding');
actor.stop();
});
it('should reach refunded as final state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_123' });
actor.send({ type: 'SHIP', shippingId: 'ship_456' });
actor.send({ type: 'DELIVER' });
actor.send({ type: 'REQUEST_RETURN' });
actor.send({ type: 'APPROVE_RETURN' });
actor.send({ type: 'REFUND_PROCESSED', amount: 100 });
expect(actor.getSnapshot().value).toBe('refunded');
actor.stop();
});
it('should reach cancelled from draft', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'CANCEL', reason: 'changed mind' });
expect(actor.getSnapshot().value).toBe('cancelled');
actor.stop();
});
});
Guards are the gatekeepers of transitions. They must be tested at their boundaries.
// guard-conditions.test.ts
describe('Order Machine -- Guard Conditions', () => {
describe('SUBMIT guard: items.length > 0', () => {
it('should block SUBMIT when cart is empty', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'SUBMIT' });
// Should remain in draft -- guard blocked the transition
expect(actor.getSnapshot().value).toBe('draft');
actor.stop();
});
it('should allow SUBMIT when cart has one item', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 50 } });
actor.send({ type: 'SUBMIT' });
expect(actor.getSnapshot().value).toBe('pending_payment');
actor.stop();
});
it('should allow SUBMIT when cart has multiple items', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 50 } });
actor.send({ type: 'ADD_ITEM', item: { id: 'i2', quantity: 2, price: 30 } });
actor.send({ type: 'SUBMIT' });
expect(actor.getSnapshot().value).toBe('pending_payment');
expect(actor.getSnapshot().context.total).toBe(110);
actor.stop();
});
it('should block SUBMIT after removing all items', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 50 } });
actor.send({ type: 'REMOVE_ITEM', itemId: 'i1' });
actor.send({ type: 'SUBMIT' });
expect(actor.getSnapshot().value).toBe('draft');
expect(actor.getSnapshot().context.items).toHaveLength(0);
actor.stop();
});
});
describe('PAYMENT_FAILED guard: retryCount < 3', () => {
it('should allow retry on first failure', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_FAILED', reason: 'timeout' });
expect(actor.getSnapshot().value).toBe('payment_retry');
expect(actor.getSnapshot().context.retryCount).toBe(1);
actor.stop();
});
it('should allow retry up to 3 times', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
// First failure and retry
actor.send({ type: 'PAYMENT_FAILED', reason: 'timeout' });
actor.send({ type: 'RETRY_PAYMENT' });
// Second failure and retry
actor.send({ type: 'PAYMENT_FAILED', reason: 'timeout' });
actor.send({ type: 'RETRY_PAYMENT' });
// Third failure and retry
actor.send({ type: 'PAYMENT_FAILED', reason: 'timeout' });
expect(actor.getSnapshot().value).toBe('payment_retry');
expect(actor.getSnapshot().context.retryCount).toBe(3);
actor.stop();
});
it('should cancel order after exhausting retries', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
// Exhaust all retries
for (let i = 0; i < 3; i++) {
actor.send({ type: 'PAYMENT_FAILED', reason: 'timeout' });
if (i < 2) actor.send({ type: 'RETRY_PAYMENT' });
}
// Fourth failure should cancel
actor.send({ type: 'RETRY_PAYMENT' });
actor.send({ type: 'PAYMENT_FAILED', reason: 'final_failure' });
expect(actor.getSnapshot().value).toBe('cancelled');
actor.stop();
});
});
});
// invalid-transitions.test.ts
describe('Order Machine -- Invalid Transitions', () => {
it('should not allow SHIP from draft state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'SHIP', shippingId: 'ship_1' });
expect(actor.getSnapshot().value).toBe('draft');
actor.stop();
});
it('should not allow DELIVER from pending_payment state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'DELIVER' });
expect(actor.getSnapshot().value).toBe('pending_payment');
actor.stop();
});
it('should not allow PAYMENT_RECEIVED from confirmed state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_1' });
// Try to receive payment again
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_2' });
expect(actor.getSnapshot().value).toBe('confirmed');
expect(actor.getSnapshot().context.paymentId).toBe('pay_1');
actor.stop();
});
it('should not allow REQUEST_RETURN from shipped state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_1' });
actor.send({ type: 'SHIP', shippingId: 'ship_1' });
actor.send({ type: 'REQUEST_RETURN' });
expect(actor.getSnapshot().value).toBe('shipped');
actor.stop();
});
it('should not process any events in final cancelled state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'CANCEL', reason: 'test' });
expect(actor.getSnapshot().value).toBe('cancelled');
// Try every event type
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_1' });
actor.send({ type: 'SHIP', shippingId: 'ship_1' });
expect(actor.getSnapshot().value).toBe('cancelled');
actor.stop();
});
it('should not process events in final refunded state', () => {
const actor = createActor(orderMachine).start();
actor.send({ type: 'ADD_ITEM', item: { id: 'i1', quantity: 1, price: 100 } });
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'PAYMENT_RECEIVED', paymentId: 'pay_1' });
actor.send({ type: 'SHIP', shippingId: 'ship_1' });
actor.send({ type: 'DELIVER' });
actor.send({ type: 'REQUEST_RETURN' });
actor.send({ type: 'APPROVE_RETURN' });
actor.send({ type: 'REFUND_PROCESSED', amount: 100 });
expect(actor.getSnapshot().value).toBe('refunded');
actor.send({ type: 'SHIP', shippingId: 'ship_2' });
expect(actor.getSnapshot().value).toBe('refunded');
actor.stop();
});
});
// entry-exit-actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createMachine, createActor } from 'xstate';
describe('Entry and Exit Actions', () => {
const onEnterConfirmed = vi.fn();
const onExitDraft = vi.fn();
const onEnterShipped = vi.fn();
const onExitShipped = vi.fn();
const sendConfirmationEmail = vi.fn();
const sendShippingNotification = vi.fn();
const machineWithActions = createMachine({
id: 'order-with-actions',
initial: 'draft',
context: { items: [] as string[] },
states: {
draft: {
exit: () => onExitDraft(),
on: {
SUBMIT: 'confirmed',
},
},
confirmed: {
entry: [
() => onEnterConfirmed(),
() => sendConfirmationEmail(),
],
on: {
SHIP: 'shipped',
},
},
shipped: {
entry: [
() => onEnterShipped(),
() => sendShippingNotification(),
],
exit: () => onExitShipped(),
on: {
DELIVER: 'delivered',
},
},
delivered: {
type: 'final',
},
},
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should execute exit action when leaving draft', () => {
const actor = createActor(machineWithActions).start();
actor.send({ type: 'SUBMIT' });
expect(onExitDraft).toHaveBeenCalledTimes(1);
actor.stop();
});
it('should execute entry actions when entering confirmed', () => {
const actor = createActor(machineWithActions).start();
actor.send({ type: 'SUBMIT' });
expect(onEnterConfirmed).toHaveBeenCalledTimes(1);
expect(sendConfirmationEmail).toHaveBeenCalledTimes(1);
actor.stop();
});
it('should execute actions in order: exit old, enter new', () => {
const callOrder: string[] = [];
onExitDraft.mockImplementation(() => callOrder.push('exit_draft'));
onEnterConfirmed.mockImplementation(() => callOrder.push('enter_confirmed'));
const actor = createActor(machineWithActions).start();
actor.send({ type: 'SUBMIT' });
expect(callOrder).toEqual(['exit_draft', 'enter_confirmed']);
actor.stop();
});
it('should execute exit and entry actions during transition through shipped', () => {
const actor = createActor(machineWithActions).start();
actor.send({ type: 'SUBMIT' });
actor.send({ type: 'SHIP' });
expect(onEnterShipped).toHaveBeenCalledTimes(1);
expect(sendShippingNotification).toHaveBeenCalledTimes(1);
actor.send({ type: 'DELIVER' });
expect(onExitShipped).toHaveBeenCalledTimes(1);
actor.stop();
});
it('should not execute entry actions if transition is blocked', () => {
// If we send an invalid event, no actions should fire
const actor = createActor(machineWithActions).start();
actor.send({ type: 'SHIP' }); // Invalid from draft
expect(onExitDraft).not.toHaveBeenCalled();
expect(onEnterShipped).not.toHaveBeenCalled();
actor.stop();
});
});
// payment-machine.ts
const paymentMachine = createMachine({
id: 'payment',
initial: 'idle',
context: {
amount: 0,
currency: 'USD',
attempts: 0,
lastError: null as string | null,
transactionId: null as string | null,
},
states: {
idle: {
on: {
INITIATE: {
target: 'processing',
guard: ({ event }) => event.amount > 0,
actions: assign({ amount: ({ event }) => event.amount }),
},
},
},
processing: {
entry: assign({ attempts: ({ context }) => context.attempts + 1 }),
on: {
SUCCESS: {
target: 'authorized',
actions: assign({ transactionId: ({ event }) => event.transactionId }),
},
FAILURE: [
{ target: 'retrying', guard: ({ context }) => context.attempts < 3 },
{ target: 'failed' },
],
TIMEOUT: [
{ target: 'retrying', guard: ({ context }) => context.attempts < 3 },
{ target: 'failed' },
],
},
},
retrying: {
after: {
2000: 'processing', // Auto-retry after 2 seconds
},
on: {
CANCEL: 'cancelled',
},
},
authorized: {
on: {
CAPTURE: 'captured',
VOID: 'voided',
},
},
captured: {
on: {
REFUND: 'refunding',
},
},
refunding: {
on: {
REFUND_COMPLETE: 'refunded',
REFUND_FAILED: 'captured', // Back to captured if refund fails
},
},
refunded: { type: 'final' },
voided: { type: 'final' },
failed: { type: 'final' },
cancelled: { type: 'final' },
},
});
// machine-test.config.ts
interface StateMachineTestConfig {
coverage: {
requireAllStatesCoverage: boolean;
requireAllTransitionsCoverage: boolean;
minimumStateCoverage: number; // percentage
minimumTransitionCoverage: number; // percentage
};
generation: {
maxPathLength: number;
maxPathsPerState: number;
includeInvalidTransitions: boolean;
includeGuardBoundaries: boolean;
};
reporting: {
generateTransitionMatrix: boolean;
generateStateDiagram: boolean;
outputDirectory: string;
format: 'json' | 'markdown' | 'html';
};
timeouts: {
stateTransitionMs: number;
asyncActionMs: number;
delayedTransitionMs: number;
};
}
const defaultConfig: StateMachineTestConfig = {
coverage: {
requireAllStatesCoverage: true,
requireAllTransitionsCoverage: true,
minimumStateCoverage: 100,
minimumTransitionCoverage: 90,
},
generation: {
maxPathLength: 20,
maxPathsPerState: 5,
includeInvalidTransitions: true,
includeGuardBoundaries: true,
},
reporting: {
generateTransitionMatrix: true,
generateStateDiagram: false,
outputDirectory: './test-artifacts/state-machine',
format: 'markdown',
},
timeouts: {
stateTransitionMs: 5000,
asyncActionMs: 10000,
delayedTransitionMs: 15000,
},
};
Define the state machine model independently of the implementation -- The model used for test generation should come from requirements, not from reading the code. Testing against the code's own model only verifies internal consistency, not correctness.
Use a transition table for systematic coverage -- Create a matrix with states as rows and events as columns. Each cell shows the expected target state or "invalid." This table is both documentation and a test generation input.
Test every guard at its boundary -- If a guard checks retryCount < 3, test with retryCount of 2 (should pass) and 3 (should fail). Boundary testing on guards catches off-by-one errors that cause the most subtle state machine bugs.
Verify context mutations alongside state changes -- A correct state transition with incorrect context is still a bug. Assert both the new state and the updated context after every transition.
Test the complete happy path end-to-end -- One test should walk the machine from initial state to a final state through the most common path. This verifies the machine works as a cohesive whole, not just in isolated transitions.
Test all paths to final states -- If the machine has multiple final states (completed, cancelled, failed), verify there is at least one test that reaches each final state.
Use spies for action verification -- Do not assert on side effects of actions (emails sent, database writes). Instead, spy on the action functions and verify they were called with the correct arguments.
Test state persistence and restoration -- If the machine state is persisted (Redux, database), test that a machine restored from persisted state behaves identically to one that arrived there through transitions.
Generate tests from the model when possible -- Use the TestPathGenerator pattern to automatically generate test paths from the model. Manual test enumeration misses paths that automated generation catches.
Maintain the model as a living document -- When requirements change, update the model first, regenerate tests, then update the implementation. The model is the contract between requirements and code.
Test time-dependent transitions explicitly -- If the machine uses delayed transitions (timeouts, debounces), test with fake timers to verify correct behavior without waiting for real time to pass.
Verify no unhandled event warnings in production -- In development, unhandled events should log warnings. In production, they should be silently ignored. Test both modes.
Testing only the happy path -- A state machine that handles the happy path correctly but crashes on unexpected events is not production-ready. Test error paths, cancellation paths, and timeout paths with equal rigor.
Coupling tests to implementation details -- Do not assert on internal implementation details like XState internals, actor references, or internal event queues. Test the observable behavior: current state, context, and triggered actions.
Ignoring dead states -- A state that cannot be reached from the initial state is a dead state. If your coverage report shows an unreachable state, it is either a model error or a test gap. Investigate, do not ignore.
Testing guards in isolation without state context -- A guard function might work correctly when tested alone but fail when the context is modified by prior transitions. Always test guards within the full state machine context.
Assuming events are processed synchronously -- In asynchronous state machines, events may be queued, batched, or delayed. Do not assume that sending an event immediately changes the state. Use proper async assertions.
Hardcoding transition sequences without documenting the path -- A test that sends 8 events in sequence without comments is unmaintainable. Document what each event is testing and why the sequence matters.
Skipping invalid transition tests because "the UI prevents it" -- The UI is not the only entry point. API calls, WebSocket messages, and race conditions can trigger events from unexpected states. The state machine must be its own safety net.
State does not change after sending an event -- Check for guard conditions blocking the transition. Log the guard evaluation result. The most common cause is a guard referencing stale context or using the wrong comparison operator.
Wrong state after a sequence of events -- Add logging to every transition to trace the actual path. Compare it against the expected path. The divergence point reveals which transition is misconfigured.
Actions execute but produce incorrect results -- Actions receive the context and event at the time of execution, not at the time of modeling. Verify that the context shape matches what the action expects by logging the full context object.
Parallel states interfere with each other -- This should not happen in a correctly modeled machine. If it does, check for shared context mutations between regions. Each parallel region should only modify its own slice of context.
Tests pass individually but fail when run together -- State machine actors must be created fresh for each test. If you are reusing an actor across tests, accumulated state from previous tests leaks into subsequent ones.
Delayed transitions fire at wrong times -- Use fake timers in tests. Real timers introduce flakiness. With fake timers, advance time explicitly and assert state changes at precise intervals.
Model and implementation diverge after refactoring -- Run the coverage reporter after every change. If any state or transition is uncovered, either the model or the implementation drifted. Reconcile them before merging.
Guard conditions pass when they should block -- Log the exact values being compared in the guard. Off-by-one errors (using <= instead of <) and type coercion issues ("3" < 3 evaluating differently than expected) are the usual culprits.
Entry actions fire multiple times -- This happens when a self-transition is modeled (a state transitions to itself). Verify whether the self-transition should re-execute entry actions or not. In XState, external self-transitions re-execute entry/exit; internal transitions do not.
Context is undefined in actions -- Ensure the machine has a properly defined initial context. If using TypeScript, verify the context type matches the actual initial value. Missing context initialization is a common source of runtime errors in actions.
- name: Install QA Skills
run: npx @qaskills/cli add state-machine-test-generator12 of 29 agents supported