Skip to main content

Testing API

Testing utilities and helpers for Stage Flow applications.

Test Engine

import { StageFlowTestEngine } from '@stage-flow/testing';

interface TestEngineOptions {
/** Whether to enable strict validation during tests */
strictValidation?: boolean;
/** Custom validation options */
validationOptions?: ValidationOptions;
/** Whether to enable development warnings */
developmentWarnings?: boolean;
/** Custom error handler for tests */
onError?: (error: Error) => void;
}

class StageFlowTestEngine<TStage extends string, TData = unknown> extends StageFlowEngine<TStage, TData> {
constructor(config: StageFlowConfig<TStage, TData>, options?: TestEngineOptions);

/** Get test-specific utilities */
test: {
/** Wait for a specific stage */
waitForStage: (stage: TStage, timeout?: number) => Promise<void>;
/** Wait for a specific event to be sent */
waitForEvent: (event: string, timeout?: number) => Promise<void>;
/** Get all stage transitions that occurred */
getTransitions: () => Array<{ from: TStage; to: TStage; event?: string; timestamp: number }>;
/** Clear transition history */
clearTransitions: () => void;
/** Mock a plugin */
mockPlugin: (name: string, mock: Partial<Plugin<TStage, TData>>) => void;
/** Restore original plugin */
restorePlugin: (name: string) => void;
};
}

Mock Utilities

import { 
createMockPlugin,
createMockMiddleware,
createSpyPlugin,
createSpyMiddleware,
createAsyncPlugin,
createAsyncMiddleware,
createErrorPlugin,
createErrorMiddleware,
waitForCalls,
resetPluginMocks,
resetMiddlewareMocks
} from '@stage-flow/testing';

interface MockPluginConfig {
/** Plugin name */
name: string;
/** Whether to track calls */
trackCalls?: boolean;
/** Custom install behavior */
install?: (engine: StageFlowEngine<any, any>) => void | Promise<void>;
/** Custom uninstall behavior */
uninstall?: (engine: StageFlowEngine<any, any>) => void | Promise<void>;
/** Custom hooks */
hooks?: Partial<Plugin<any, any>['hooks']>;
/** Custom state */
state?: Record<string, unknown>;
}

interface MockMiddlewareConfig {
/** Middleware name */
name: string;
/** Whether to track calls */
trackCalls?: boolean;
/** Custom execute behavior */
execute?: (context: TransitionContext<any, any>, next: () => Promise<void>) => Promise<void>;
}

/** Create a mock plugin */
function createMockPlugin(config: MockPluginConfig): Plugin<any, any>;

/** Create a mock middleware */
function createMockMiddleware(config: MockMiddlewareConfig): Middleware<any, any>;

/** Create a spy plugin that tracks all calls */
function createSpyPlugin(name: string): Plugin<any, any> & {
calls: Array<{ method: string; args: unknown[]; timestamp: number }>;
reset: () => void;
};

/** Create a spy middleware that tracks all calls */
function createSpyMiddleware(name: string): Middleware<any, any> & {
calls: Array<{ context: TransitionContext<any, any>; timestamp: number }>;
reset: () => void;
};

/** Create an async plugin with delays */
function createAsyncPlugin(name: string, delay?: number): Plugin<any, any>;

/** Create an async middleware with delays */
function createAsyncMiddleware(name: string, delay?: number): Middleware<any, any>;

/** Create a plugin that throws errors */
function createErrorPlugin(name: string, error?: Error): Plugin<any, any>;

/** Create a middleware that throws errors */
function createErrorMiddleware(name: string, error?: Error): Middleware<any, any>;

/** Wait for a specific number of calls to a mock */
function waitForCalls(mock: any, count: number, timeout?: number): Promise<void>;

/** Reset all plugin mocks */
function resetPluginMocks(): void;

/** Reset all middleware mocks */
function resetMiddlewareMocks(): void;

React Testing Utilities

import { 
renderStageFlow,
createMockStageComponent,
createTestProvider,
createStageFlowTestSetup,
createStageFlowInteractions,
waitForReactUpdate,
StageFlowTestInteractions,
stageFlowMatchers
} from '@stage-flow/testing';

interface StageFlowRenderResult<TStage extends string, TData = unknown> {
/** The test engine instance */
engine: StageFlowTestEngine<TStage, TData>;
/** The rendered component */
component: RenderResult;
/** Test utilities */
utils: {
/** Wait for stage change */
waitForStage: (stage: TStage) => Promise<void>;
/** Wait for event to be sent */
waitForEvent: (event: string) => Promise<void>;
/** Get current stage */
getCurrentStage: () => TStage;
/** Get current data */
getCurrentData: () => TData | undefined;
/** Send event */
send: (event: string, data?: TData) => Promise<void>;
/** Navigate to stage */
goTo: (stage: TStage, data?: TData) => Promise<void>;
};
}

interface RenderStageFlowOptions<TStage extends string, TData = unknown> {
/** Stage flow configuration */
config: StageFlowConfig<TStage, TData>;
/** Test engine options */
engineOptions?: TestEngineOptions;
/** React testing library options */
renderOptions?: RenderOptions;
/** Custom stage components */
stageComponents?: Partial<Record<TStage, ComponentType<any>>>;
/** Whether to wrap with error boundary */
withErrorBoundary?: boolean;
/** Custom error boundary props */
errorBoundaryProps?: Partial<StageErrorBoundaryProps>;
}

interface StageFlowTestSetup<TStage extends string, TData = unknown> {
/** Test engine */
engine: StageFlowTestEngine<TStage, TData>;
/** Test provider component */
TestProvider: ComponentType<{ children: React.ReactNode }>;
/** Test utilities */
utils: {
/** Wait for stage change */
waitForStage: (stage: TStage) => Promise<void>;
/** Wait for event to be sent */
waitForEvent: (event: string) => Promise<void>;
/** Get current stage */
getCurrentStage: () => TStage;
/** Get current data */
getCurrentData: () => TData | undefined;
/** Send event */
send: (event: string, data?: TData) => Promise<void>;
/** Navigate to stage */
goTo: (stage: TStage, data?: TData) => Promise<void>;
};
}

/** Render a component with stage flow context */
function renderStageFlow<TStage extends string, TData = unknown>(
component: React.ReactElement,
options: RenderStageFlowOptions<TStage, TData>
): StageFlowRenderResult<TStage, TData>;

/** Create a mock stage component */
function createMockStageComponent<TProps = any>(
name: string,
props?: Partial<TProps>
): ComponentType<TProps>;

/** Create a test provider component */
function createTestProvider<TStage extends string, TData = unknown>(
config: StageFlowConfig<TStage, TData>,
options?: TestEngineOptions
): ComponentType<{ children: React.ReactNode }>;

/** Create a complete test setup */
function createStageFlowTestSetup<TStage extends string, TData = unknown>(
config: StageFlowConfig<TStage, TData>,
options?: TestEngineOptions
): StageFlowTestSetup<TStage, TData>;

/** Create test interactions */
function createStageFlowInteractions<TStage extends string, TData = unknown>(
engine: StageFlowTestEngine<TStage, TData>
): StageFlowTestInteractions<TStage, TData>;

/** Wait for React to update */
function waitForReactUpdate(): Promise<void>;

/** Test interactions interface */
interface StageFlowTestInteractions<TStage extends string, TData = unknown> {
/** Wait for stage change */
waitForStage: (stage: TStage) => Promise<void>;
/** Wait for event to be sent */
waitForEvent: (event: string) => Promise<void>;
/** Get current stage */
getCurrentStage: () => TStage;
/** Get current data */
getCurrentData: () => TData | undefined;
/** Send event */
send: (event: string, data?: TData) => Promise<void>;
/** Navigate to stage */
goTo: (stage: TStage, data?: TData) => Promise<void>;
/** Get transition history */
getTransitions: () => Array<{ from: TStage; to: TStage; event?: string; timestamp: number }>;
/** Clear transition history */
clearTransitions: () => void;
}

/** Jest matchers for stage flow testing */
const stageFlowMatchers: {
toBeInStage: (stage: string) => CustomMatcherResult;
toHaveTransitionedTo: (stage: string) => CustomMatcherResult;
toHaveSentEvent: (event: string) => CustomMatcherResult;
toHaveData: (data: unknown) => CustomMatcherResult;
};

Usage Examples

Basic Engine Testing

import { StageFlowTestEngine } from '@stage-flow/testing';

describe('Stage Flow Engine', () => {
it('should transition between stages', async () => {
const config: StageFlowConfig<'idle' | 'loading' | 'success'> = {
initial: 'idle',
stages: [
{
name: 'idle',
transitions: [{ target: 'loading', event: 'start' }]
},
{
name: 'loading',
transitions: [
{ target: 'success', event: 'complete' },
{ target: 'idle', event: 'cancel' }
]
},
{
name: 'success',
transitions: [{ target: 'idle', event: 'reset' }]
}
]
};

const engine = new StageFlowTestEngine(config);
await engine.start();

expect(engine.getCurrentStage()).toBe('idle');

await engine.send('start');
await engine.test.waitForStage('loading');
expect(engine.getCurrentStage()).toBe('loading');

await engine.send('complete');
await engine.test.waitForStage('success');
expect(engine.getCurrentStage()).toBe('success');
});
});

Mock Engine Testing

import { createMockPlugin, createSpyMiddleware } from '@stage-flow/testing';

describe('Stage Flow with Mocks', () => {
it('should handle async operations with mocks', async () => {
const mockPlugin = createMockPlugin({
name: 'test-plugin',
trackCalls: true,
install: async (engine) => {
console.log('Mock plugin installed');
}
});

const spyMiddleware = createSpyMiddleware('test-middleware');

const config: StageFlowConfig<'idle' | 'loading' | 'success'> = {
initial: 'idle',
stages: [
{
name: 'idle',
transitions: [{ target: 'loading', event: 'start' }]
},
{
name: 'loading',
transitions: [{ target: 'success', event: 'complete' }]
},
{
name: 'success',
transitions: [{ target: 'idle', event: 'reset' }]
}
]
};

const engine = new StageFlowTestEngine(config, {
plugins: [mockPlugin],
middleware: [spyMiddleware]
});

await engine.start();
await engine.send('start');
await engine.test.waitForStage('loading');

expect(spyMiddleware.calls).toHaveLength(1);
expect(spyMiddleware.calls[0].context.from).toBe('idle');
expect(spyMiddleware.calls[0].context.to).toBe('loading');
});
});

React Component Testing

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { renderStageFlow } from '@stage-flow/testing';
import { useStageFlow } from '@stage-flow/react';

function TestComponent() {
const { currentStage, send, isTransitioning } = useStageFlow();

return (
<div>
<p>Current stage: {currentStage}</p>
<button
onClick={() => send('start')}
disabled={isTransitioning}
>
Start
</button>
<button
onClick={() => send('complete')}
disabled={isTransitioning}
>
Complete
</button>
</div>
);
}

describe('TestComponent', () => {
it('should render and handle stage transitions', async () => {
const config: StageFlowConfig<'idle' | 'loading' | 'success'> = {
initial: 'idle',
stages: [
{
name: 'idle',
transitions: [{ target: 'loading', event: 'start' }]
},
{
name: 'loading',
transitions: [{ target: 'success', event: 'complete' }]
},
{
name: 'success',
transitions: [{ target: 'idle', event: 'reset' }]
}
]
};

const { engine, utils } = renderStageFlow(<TestComponent />, { config });

expect(screen.getByText('Current stage: idle')).toBeInTheDocument();

const startButton = screen.getByText('Start');
expect(startButton).not.toBeDisabled();

fireEvent.click(startButton);
await utils.waitForStage('loading');

expect(screen.getByText('Current stage: loading')).toBeInTheDocument();
expect(startButton).toBeDisabled();

const completeButton = screen.getByText('Complete');
fireEvent.click(completeButton);
await utils.waitForStage('success');

expect(screen.getByText('Current stage: success')).toBeInTheDocument();
});
});

User Interaction Simulation

import { createStageFlowInteractions } from '@stage-flow/testing';

describe('User Interaction Simulation', () => {
it('should simulate complex user flows', async () => {
const engine = new StageFlowTestEngine(config);
await engine.start();

const interactions = createStageFlowInteractions(engine);

await interactions.send('start');
await interactions.waitForStage('loading');

await interactions.send('complete', { result: 'success' });
await interactions.waitForStage('success');

expect(interactions.getCurrentStage()).toBe('success');
expect(interactions.getCurrentData()?.result).toBe('success');
});
});

Integration Testing

import { renderStageFlow } from '@stage-flow/testing';

describe('Integration Tests', () => {
it('should handle complete application flow', async () => {
const config = createAppConfig();
const { engine, utils } = renderStageFlow(<App />, { config });

// Simulate user login
await utils.send('login', { username: 'test', password: 'test' });
await utils.waitForStage('authenticated');

// Simulate data loading
await utils.send('loadData');
await utils.waitForStage('dataLoaded');

// Verify final state
expect(utils.getCurrentStage()).toBe('dataLoaded');
expect(utils.getCurrentData()?.user).toBeDefined();
expect(utils.getCurrentData()?.data).toBeDefined();
});
});

Test Configuration

import { StageFlowTestEngine } from '@stage-flow/testing';

const testConfig = {
strictValidation: true,
developmentWarnings: false,
onError: (error) => {
console.error('Test error:', error);
}
};

const engine = new StageFlowTestEngine(config, testConfig);

Async Testing Patterns

describe('Async Operations', () => {
it('should handle async transitions', async () => {
const engine = new StageFlowTestEngine(config);
await engine.start();

// Start async operation
const transitionPromise = engine.send('startAsync');

// Wait for intermediate state
await engine.test.waitForStage('processing');

// Complete async operation
await engine.send('complete');
await transitionPromise;

expect(engine.getCurrentStage()).toBe('success');
});

it('should handle timeouts', async () => {
const engine = new StageFlowTestEngine(config);
await engine.start();

await engine.send('startWithTimeout');

// Wait for timeout
await new Promise(resolve => setTimeout(resolve, 1000));

expect(engine.getCurrentStage()).toBe('timeout');
});
});

Jest Matchers

import { stageFlowMatchers } from '@stage-flow/testing';

// Extend Jest expect
expect.extend(stageFlowMatchers);

describe('Stage Flow Matchers', () => {
it('should use custom matchers', async () => {
const engine = new StageFlowTestEngine(config);
await engine.start();

expect(engine).toBeInStage('idle');

await engine.send('start');
await engine.test.waitForStage('loading');

expect(engine).toHaveTransitionedTo('loading');
expect(engine).toHaveSentEvent('start');
expect(engine).toHaveData({ loading: true });
});
});