Skip to main content

Testing State Machines

Testing state machines requires a systematic approach to ensure all transitions, guards, actions, and error conditions work correctly. This guide covers comprehensive testing strategies and patterns.

Testing Philosophy​

State machines are inherently testable because they have:

  • Predictable behavior - Same input always produces same output
  • Clear boundaries - Well-defined states and transitions
  • Isolated logic - Guards and actions can be tested independently
  • Deterministic flow - No hidden state or side effects

Unit Testing Basics​

Testing State Transitions​

import { StateMachine } from '@jewel998/state-machine';

describe('Order State Machine', () => {
let orderMachine: any;

beforeEach(() => {
orderMachine = StateMachine.definitionBuilder()
.initialState('DRAFT')
.state('DRAFT')
.state('CONFIRMED')
.state('SHIPPED')
.state('DELIVERED')
.transition('DRAFT', 'CONFIRMED', 'confirm')
.transition('CONFIRMED', 'SHIPPED', 'ship')
.transition('SHIPPED', 'DELIVERED', 'deliver')
.buildDefinition();
});

it('should transition from DRAFT to CONFIRMED', () => {
const result = orderMachine.processEvent('DRAFT', 'confirm', {});

expect(result.success).toBe(true);
expect(result.newState).toBe('CONFIRMED');
});

it('should reject invalid transitions', () => {
const result = orderMachine.processEvent('DRAFT', 'ship', {});

expect(result.success).toBe(false);
expect(result.newState).toBe('DRAFT'); // State unchanged
});

it('should handle non-existent events', () => {
const result = orderMachine.processEvent('DRAFT', 'invalid_event', {});

expect(result.success).toBe(false);
});
});

Testing Guards​

describe('Order Guards', () => {
let orderMachine: any;

beforeEach(() => {
orderMachine = StateMachine.definitionBuilder()
.initialState('CART')
.state('CART')
.state('CHECKOUT')
.transition('CART', 'CHECKOUT', 'proceed')
.guard((context) => context.items.length > 0)
.guard((context) => context.user.isAuthenticated)
.buildDefinition();
});

it('should allow transition when all guards pass', () => {
const context = {
items: [{ id: 1, name: 'Product' }],
user: { isAuthenticated: true },
};

const result = orderMachine.processEvent('CART', 'proceed', context);
expect(result.success).toBe(true);
});

it('should block transition when guard fails - empty cart', () => {
const context = {
items: [], // Empty cart
user: { isAuthenticated: true },
};

const result = orderMachine.processEvent('CART', 'proceed', context);
expect(result.success).toBe(false);
});

it('should block transition when guard fails - not authenticated', () => {
const context = {
items: [{ id: 1, name: 'Product' }],
user: { isAuthenticated: false },
};

const result = orderMachine.processEvent('CART', 'proceed', context);
expect(result.success).toBe(false);
});

it('should provide available events when transition blocked', () => {
const context = { items: [], user: { isAuthenticated: false } };

const availableEvents = orderMachine.getAvailableEvents('CART', context);
expect(availableEvents).toEqual([]); // No events available due to failed guards
});
});

Testing Actions​

describe('Order Actions', () => {
let orderMachine: any;
let mockEmailService: jest.Mock;
let mockInventoryService: jest.Mock;

beforeEach(() => {
mockEmailService = jest.fn();
mockInventoryService = jest.fn();

orderMachine = StateMachine.definitionBuilder()
.initialState('CONFIRMED')
.state('CONFIRMED')
.state('SHIPPED')
.transition('CONFIRMED', 'SHIPPED', 'ship')
.action((context) => {
// Reserve inventory
mockInventoryService(context.items);

// Send notification
mockEmailService(context.user.email, 'Order shipped');

// Update context
context.shippedAt = new Date();
context.trackingNumber = 'TRK123456';
})
.buildDefinition();
});

it('should execute actions during transition', () => {
const context = {
items: [{ id: 1, quantity: 2 }],
user: { email: 'user@example.com' },
};

const result = orderMachine.processEvent('CONFIRMED', 'ship', context);

expect(result.success).toBe(true);
expect(mockInventoryService).toHaveBeenCalledWith(context.items);
expect(mockEmailService).toHaveBeenCalledWith('user@example.com', 'Order shipped');
expect(context.shippedAt).toBeDefined();
expect(context.trackingNumber).toBe('TRK123456');
});
});

Testing State Entry/Exit Actions​

describe('State Entry/Exit Actions', () => {
let orderMachine: any;
let mockLogger: jest.Mock;
let mockMetrics: jest.Mock;

beforeEach(() => {
mockLogger = jest.fn();
mockMetrics = jest.fn();

orderMachine = StateMachine.definitionBuilder()
.initialState('IDLE')
.state('IDLE')
.state('PROCESSING')
.state('COMPLETED')

.onStateEntry('PROCESSING', (context) => {
mockLogger('Processing started', context.orderId);
mockMetrics('processing.started');
context.processingStartTime = Date.now();
})

.onStateExit('PROCESSING', (context) => {
const duration = Date.now() - context.processingStartTime;
mockLogger('Processing completed', { orderId: context.orderId, duration });
mockMetrics('processing.completed', duration);
})

.transition('IDLE', 'PROCESSING', 'start')
.transition('PROCESSING', 'COMPLETED', 'complete')
.buildDefinition();
});

it('should execute entry action when entering state', () => {
const context = { orderId: 'ORD-123' };

const result = orderMachine.processEvent('IDLE', 'start', context);

expect(result.success).toBe(true);
expect(mockLogger).toHaveBeenCalledWith('Processing started', 'ORD-123');
expect(mockMetrics).toHaveBeenCalledWith('processing.started');
expect(context.processingStartTime).toBeDefined();
});

it('should execute exit action when leaving state', () => {
const context = {
orderId: 'ORD-123',
processingStartTime: Date.now() - 1000, // 1 second ago
};

const result = orderMachine.processEvent('PROCESSING', 'complete', context);

expect(result.success).toBe(true);
expect(mockLogger).toHaveBeenCalledWith(
'Processing completed',
expect.objectContaining({ orderId: 'ORD-123' })
);
expect(mockMetrics).toHaveBeenCalledWith('processing.completed', expect.any(Number));
});
});

Testing Async Operations​

Testing Async Guards​

describe('Async Guards', () => {
let authMachine: any;
let mockAuthService: jest.Mock;

beforeEach(() => {
mockAuthService = jest.fn();

authMachine = StateMachine.definitionBuilder()
.initialState('LOGGED_OUT')
.state('LOGGED_OUT')
.state('LOGGED_IN')
.transition('LOGGED_OUT', 'LOGGED_IN', 'login')
.guard(async (context) => {
const isValid = await mockAuthService.validateCredentials(
context.username,
context.password
);
return isValid;
})
.buildDefinition();
});

it('should allow transition when async guard passes', async () => {
mockAuthService.validateCredentials.mockResolvedValue(true);

const context = { username: 'user', password: 'pass' };
const result = await authMachine.processEventAsync('LOGGED_OUT', 'login', context);

expect(result.success).toBe(true);
expect(result.newState).toBe('LOGGED_IN');
expect(mockAuthService.validateCredentials).toHaveBeenCalledWith('user', 'pass');
});

it('should block transition when async guard fails', async () => {
mockAuthService.validateCredentials.mockResolvedValue(false);

const context = { username: 'user', password: 'wrong' };
const result = await authMachine.processEventAsync('LOGGED_OUT', 'login', context);

expect(result.success).toBe(false);
expect(result.newState).toBe('LOGGED_OUT');
});

it('should handle async guard errors', async () => {
mockAuthService.validateCredentials.mockRejectedValue(new Error('Service unavailable'));

const context = { username: 'user', password: 'pass' };

await expect(authMachine.processEventAsync('LOGGED_OUT', 'login', context)).rejects.toThrow(
'Service unavailable'
);
});
});

Testing Async Actions​

describe('Async Actions', () => {
let orderMachine: any;
let mockPaymentService: jest.Mock;
let mockInventoryService: jest.Mock;

beforeEach(() => {
mockPaymentService = jest.fn();
mockInventoryService = jest.fn();

orderMachine = StateMachine.definitionBuilder()
.initialState('PENDING')
.state('PENDING')
.state('CONFIRMED')
.transition('PENDING', 'CONFIRMED', 'confirm')
.action(async (context) => {
await mockPaymentService.charge(context.payment);
await mockInventoryService.reserve(context.items);
context.confirmedAt = new Date();
})
.buildDefinition();
});

it('should execute async actions successfully', async () => {
mockPaymentService.charge.mockResolvedValue({ transactionId: 'TXN-123' });
mockInventoryService.reserve.mockResolvedValue({ reservationId: 'RES-456' });

const context = {
payment: { amount: 100, method: 'card' },
items: [{ id: 1, quantity: 2 }],
};

const result = await orderMachine.processEventAsync('PENDING', 'confirm', context);

expect(result.success).toBe(true);
expect(mockPaymentService.charge).toHaveBeenCalledWith(context.payment);
expect(mockInventoryService.reserve).toHaveBeenCalledWith(context.items);
expect(context.confirmedAt).toBeDefined();
});

it('should handle async action failures', async () => {
mockPaymentService.charge.mockRejectedValue(new Error('Payment failed'));

const context = {
payment: { amount: 100, method: 'card' },
items: [{ id: 1, quantity: 2 }],
};

await expect(orderMachine.processEventAsync('PENDING', 'confirm', context)).rejects.toThrow(
'Payment failed'
);
});
});

Testing Transactions and Rollbacks​

describe('Transaction Rollbacks', () => {
let orderMachine: any;
let mockDatabase: any;
let mockPaymentService: any;

beforeEach(() => {
mockDatabase = {
createOrder: jest.fn(),
deleteOrder: jest.fn(),
reserveInventory: jest.fn(),
releaseInventory: jest.fn(),
};

mockPaymentService = {
charge: jest.fn(),
refund: jest.fn(),
};

orderMachine = StateMachine.definitionBuilder()
.initialState('DRAFT')
.state('DRAFT')
.state('CONFIRMED')
.state('FAILED')
.transition('DRAFT', 'CONFIRMED', 'confirm')
.transaction(
// Main transaction
async (context) => {
await mockDatabase.createOrder(context.order);
await mockDatabase.reserveInventory(context.items);
await mockPaymentService.charge(context.payment);
},
// Rollback function
async (context, error) => {
await mockPaymentService.refund(context.payment);
await mockDatabase.releaseInventory(context.items);
await mockDatabase.deleteOrder(context.order.id);
}
)
.buildDefinition();
});

it('should complete transaction successfully', async () => {
mockDatabase.createOrder.mockResolvedValue({ id: 'ORD-123' });
mockDatabase.reserveInventory.mockResolvedValue({ reserved: true });
mockPaymentService.charge.mockResolvedValue({ transactionId: 'TXN-456' });

const context = {
order: { id: 'ORD-123', total: 100 },
items: [{ id: 1, quantity: 2 }],
payment: { method: 'card', amount: 100 },
};

const result = await orderMachine.processEventAsync('DRAFT', 'confirm', context);

expect(result.success).toBe(true);
expect(result.newState).toBe('CONFIRMED');
expect(result.rollbackExecuted).toBe(false);

expect(mockDatabase.createOrder).toHaveBeenCalledWith(context.order);
expect(mockDatabase.reserveInventory).toHaveBeenCalledWith(context.items);
expect(mockPaymentService.charge).toHaveBeenCalledWith(context.payment);
});

it('should execute rollback when transaction fails', async () => {
mockDatabase.createOrder.mockResolvedValue({ id: 'ORD-123' });
mockDatabase.reserveInventory.mockResolvedValue({ reserved: true });
mockPaymentService.charge.mockRejectedValue(new Error('Payment failed'));

// Rollback mocks
mockPaymentService.refund.mockResolvedValue({ refunded: true });
mockDatabase.releaseInventory.mockResolvedValue({ released: true });
mockDatabase.deleteOrder.mockResolvedValue({ deleted: true });

const context = {
order: { id: 'ORD-123', total: 100 },
items: [{ id: 1, quantity: 2 }],
payment: { method: 'card', amount: 100 },
};

const result = await orderMachine.processEventAsync('DRAFT', 'confirm', context);

expect(result.success).toBe(false);
expect(result.newState).toBe('DRAFT'); // State unchanged
expect(result.rollbackExecuted).toBe(true);
expect(result.error?.message).toBe('Payment failed');

// Verify rollback was executed
expect(mockPaymentService.refund).toHaveBeenCalledWith(context.payment);
expect(mockDatabase.releaseInventory).toHaveBeenCalledWith(context.items);
expect(mockDatabase.deleteOrder).toHaveBeenCalledWith('ORD-123');
});
});

Testing Middleware​

describe('Middleware Testing', () => {
let machine: any;
let mockMiddleware: any;

beforeEach(() => {
mockMiddleware = {
name: 'test-middleware',
priority: 0,
enabled: true,
actionMiddleware: jest.fn(async (context, next) => {
context.currentContext.middlewareExecuted = true;
const result = await next();
return {
...result,
metadata: { ...result.metadata, processed: true },
};
}),
};

machine = StateMachine.definitionBuilder()
.initialState('A')
.state('A')
.state('B')
.transition('A', 'B', 'go')
.action((context) => {
context.actionExecuted = true;
})
.addMiddleware(mockMiddleware)
.buildDefinition();
});

it('should execute middleware during transitions', async () => {
const context = {};

const result = await machine.processEventAsync('A', 'go', context);

expect(result.success).toBe(true);
expect(context.middlewareExecuted).toBe(true);
expect(context.actionExecuted).toBe(true);
expect(result.metadata?.processed).toBe(true);
expect(mockMiddleware.actionMiddleware).toHaveBeenCalled();
});

it('should handle middleware errors', async () => {
mockMiddleware.actionMiddleware.mockImplementation(async () => {
throw new Error('Middleware error');
});

const context = {};

await expect(machine.processEventAsync('A', 'go', context)).rejects.toThrow('Middleware error');
});
});

Integration Testing​

Testing Complete Workflows​

describe('Order Processing Workflow', () => {
let orderWorkflow: any;
let mockServices: any;

beforeEach(() => {
mockServices = {
inventory: {
checkAvailability: jest.fn(),
reserve: jest.fn(),
release: jest.fn(),
},
payment: {
validate: jest.fn(),
charge: jest.fn(),
refund: jest.fn(),
},
shipping: {
calculateCost: jest.fn(),
createShipment: jest.fn(),
},
notification: {
sendEmail: jest.fn(),
sendSMS: jest.fn(),
},
};

orderWorkflow = createOrderWorkflow(mockServices);
});

it('should process complete order workflow successfully', async () => {
// Setup mocks
mockServices.inventory.checkAvailability.mockResolvedValue(true);
mockServices.inventory.reserve.mockResolvedValue({ reservationId: 'RES-123' });
mockServices.payment.validate.mockResolvedValue(true);
mockServices.payment.charge.mockResolvedValue({ transactionId: 'TXN-456' });
mockServices.shipping.calculateCost.mockResolvedValue(10.99);
mockServices.shipping.createShipment.mockResolvedValue({ trackingId: 'TRK-789' });
mockServices.notification.sendEmail.mockResolvedValue(true);

const context = {
order: { id: 'ORD-123', items: [{ id: 1, quantity: 2 }] },
customer: { email: 'customer@example.com' },
payment: { method: 'card', token: 'tok_123' },
};

// Execute complete workflow
let result = await orderWorkflow.processEventAsync('DRAFT', 'validate', context);
expect(result.success).toBe(true);
expect(result.newState).toBe('VALIDATED');

result = await orderWorkflow.processEventAsync('VALIDATED', 'confirm', context);
expect(result.success).toBe(true);
expect(result.newState).toBe('CONFIRMED');

result = await orderWorkflow.processEventAsync('CONFIRMED', 'ship', context);
expect(result.success).toBe(true);
expect(result.newState).toBe('SHIPPED');

// Verify all services were called
expect(mockServices.inventory.checkAvailability).toHaveBeenCalled();
expect(mockServices.payment.charge).toHaveBeenCalled();
expect(mockServices.shipping.createShipment).toHaveBeenCalled();
expect(mockServices.notification.sendEmail).toHaveBeenCalled();
});

it('should handle workflow failures with proper rollback', async () => {
// Setup mocks - payment fails
mockServices.inventory.checkAvailability.mockResolvedValue(true);
mockServices.inventory.reserve.mockResolvedValue({ reservationId: 'RES-123' });
mockServices.payment.validate.mockResolvedValue(true);
mockServices.payment.charge.mockRejectedValue(new Error('Payment declined'));

// Rollback mocks
mockServices.inventory.release.mockResolvedValue(true);
mockServices.payment.refund.mockResolvedValue(true);

const context = {
order: { id: 'ORD-123', items: [{ id: 1, quantity: 2 }] },
customer: { email: 'customer@example.com' },
payment: { method: 'card', token: 'tok_123' },
};

// Validate should succeed
let result = await orderWorkflow.processEventAsync('DRAFT', 'validate', context);
expect(result.success).toBe(true);

// Confirm should fail and rollback
result = await orderWorkflow.processEventAsync('VALIDATED', 'confirm', context);
expect(result.success).toBe(false);
expect(result.rollbackExecuted).toBe(true);
expect(result.error?.message).toBe('Payment declined');

// Verify rollback was executed
expect(mockServices.inventory.release).toHaveBeenCalled();
});
});

Performance Testing​

describe('Performance Tests', () => {
let machine: any;

beforeEach(() => {
machine = StateMachine.definitionBuilder()
.initialState('A')
.state('A')
.state('B')
.transition('A', 'B', 'go')
.buildDefinition();
});

it('should handle high-frequency transitions efficiently', () => {
const iterations = 100000;
const contexts = Array.from({ length: iterations }, (_, i) => ({ id: i }));

const startTime = Date.now();

for (let i = 0; i < iterations; i++) {
const result = machine.processEvent('A', 'go', contexts[i]);
expect(result.success).toBe(true);
}

const endTime = Date.now();
const duration = endTime - startTime;
const opsPerSecond = iterations / (duration / 1000);

console.log(`Processed ${iterations} transitions in ${duration}ms`);
console.log(`Performance: ${opsPerSecond.toFixed(0)} ops/second`);

// Assert minimum performance threshold
expect(opsPerSecond).toBeGreaterThan(10000); // At least 10k ops/second
});

it('should scale with multiple concurrent state machines', async () => {
const machineCount = 1000;
const machines = Array.from({ length: machineCount }, () =>
StateMachine.definitionBuilder()
.initialState('A')
.state('A')
.state('B')
.transition('A', 'B', 'go')
.buildDefinition()
);

const startTime = Date.now();

const promises = machines.map(async (m, i) => {
const result = await m.processEventAsync('A', 'go', { id: i });
expect(result.success).toBe(true);
return result;
});

await Promise.all(promises);

const endTime = Date.now();
const duration = endTime - startTime;

console.log(`Processed ${machineCount} concurrent machines in ${duration}ms`);

// Assert reasonable concurrent performance
expect(duration).toBeLessThan(1000); // Should complete within 1 second
});
});

Test Utilities and Helpers​

// Test utilities for common patterns
export class StateMachineTestUtils {
static createMockContext(overrides = {}) {
return {
userId: 'test-user',
sessionId: 'test-session',
timestamp: Date.now(),
...overrides,
};
}

static async executeTransitionSequence(
machine: any,
transitions: Array<{ state: string; event: string; context?: any }>
) {
let currentState = transitions[0].state;

for (const { state, event, context } of transitions) {
expect(currentState).toBe(state);

const result = await machine.processEventAsync(state, event, context || {});
expect(result.success).toBe(true);

currentState = result.newState;
}

return currentState;
}

static expectTransitionFailure(machine: any, fromState: string, event: string, context = {}) {
const result = machine.processEvent(fromState, event, context);
expect(result.success).toBe(false);
expect(result.newState).toBe(fromState); // State should remain unchanged
}

static expectGuardFailure(machine: any, fromState: string, event: string, context = {}) {
// Should be able to check available events
const availableEvents = machine.getAvailableEvents(fromState, context);
expect(availableEvents).not.toContain(event);

// Transition should fail
this.expectTransitionFailure(machine, fromState, event, context);
}
}

// Usage in tests
describe('Using Test Utilities', () => {
it('should use helper methods for cleaner tests', async () => {
const context = StateMachineTestUtils.createMockContext({
hasPermission: true,
});

const finalState = await StateMachineTestUtils.executeTransitionSequence(machine, [
{ state: 'DRAFT', event: 'validate' },
{ state: 'VALIDATED', event: 'confirm', context },
{ state: 'CONFIRMED', event: 'ship' },
]);

expect(finalState).toBe('SHIPPED');
});
});

Best Practices​

1. Test State Machine Behavior, Not Implementation​

// ✅ Good - tests behavior
it('should prevent checkout with empty cart', () => {
const result = machine.processEvent('CART', 'checkout', { items: [] });
expect(result.success).toBe(false);
});

// ❌ Poor - tests implementation details
it('should call isEmpty function on cart', () => {
const spy = jest.spyOn(cart, 'isEmpty');
machine.processEvent('CART', 'checkout', { items: [] });
expect(spy).toHaveBeenCalled();
});

2. Use Descriptive Test Names​

// ✅ Good - describes the scenario and expected outcome
it('should reject login when user account is locked after 3 failed attempts', () => {
// test implementation
});

// ❌ Poor - vague and unhelpful
it('should test login', () => {
// test implementation
});

3. Test Edge Cases and Error Conditions​

describe('Edge Cases', () => {
it('should handle null context gracefully', () => {
const result = machine.processEvent('IDLE', 'start', null);
expect(result.success).toBe(false);
});

it('should handle undefined event gracefully', () => {
const result = machine.processEvent('IDLE', undefined, {});
expect(result.success).toBe(false);
});

it('should handle concurrent transitions safely', async () => {
const promises = [
machine.processEventAsync('IDLE', 'start', { id: 1 }),
machine.processEventAsync('IDLE', 'start', { id: 2 }),
];

const results = await Promise.all(promises);
results.forEach((result) => {
expect(result.success).toBe(true);
});
});
});

4. Mock External Dependencies​

// ✅ Good - mocks external services
beforeEach(() => {
mockEmailService = jest.fn();
mockPaymentGateway = jest.fn();

// Inject mocks into state machine
machine = createOrderMachine({
emailService: mockEmailService,
paymentGateway: mockPaymentGateway,
});
});

5. Test Middleware Integration​

it('should execute middleware in correct order', async () => {
const executionOrder: string[] = [];

const middleware1 = createTestMiddleware('first', -100, executionOrder);
const middleware2 = createTestMiddleware('second', 0, executionOrder);
const middleware3 = createTestMiddleware('third', 100, executionOrder);

machine.addMiddleware(middleware1);
machine.addMiddleware(middleware2);
machine.addMiddleware(middleware3);

await machine.processEventAsync('A', 'go', {});

expect(executionOrder).toEqual([
'first-before',
'second-before',
'third-before',
'third-after',
'second-after',
'first-after',
]);
});

Next Steps​