Advanced Topics
Testing
Testing StateFlow applications requires understanding how to verify state transitions, signal handling, and asynchronous operations. This guide covers comprehensive testing strategies from unit tests to integration testing.
Testing Philosophy
StateFlow's deterministic design makes testing straightforward:
- State transitions are pure functions - Easy to test in isolation
- Signals provide explicit feedback - Clear assertions on results
- Immutable states - No side effects to worry about
- Type safety - Compile-time guarantees reduce runtime test complexity
Unit Testing State Definitions
Testing State Builders
Start by testing your state definitions are constructed correctly:
import { describe, test, expect } from 'vitest';
import { defineState } from '@video/state-flow';
describe('UserState', () => {
test('should build state with correct variants', () => {
const userState = defineState<{ id: string; name: string }>()
.name("user")
.variant("guest", true)
.variant("authenticated")
.variant("admin")
.build();
// Test state factory functions exist
expect(typeof userState.guest).toBe('function');
expect(typeof userState.authenticated).toBe('function');
expect(typeof userState.admin).toBe('function');
// Test state creation
const guestUser = userState.guest({ id: '', name: 'Guest' });
expect(guestUser.id).toBe('');
expect(guestUser.name).toBe('Guest');
});
test('should freeze state instances', () => {
const userState = defineState<{ name: string }>()
.name("user")
.variant("active", true)
.build();
const user = userState.active({ name: 'Alice' });
// Should be frozen
expect(Object.isFrozen(user)).toBe(true);
expect(() => {
(user as any).name = 'Bob';
}).toThrow();
});
});
Testing Signal Definitions
Verify your signals are created with correct types and parameters:
describe('UserSignals', () => {
const signals = {
login: defineSignal<{ email: string; password: string }>("login"),
logout: defineSignal("logout"),
updateProfile: defineSignal<{ name?: string; email?: string }>("updateProfile")
};
test('should create parameterized signals correctly', () => {
const loginSignal = signals.login({
email: 'test@example.com',
password: 'secret'
});
expect(loginSignal[Symbol.toStringTag]).toBe('login');
expect(loginSignal.email).toBe('test@example.com');
expect(loginSignal.password).toBe('secret');
});
test('should create parameterless signals correctly', () => {
const logoutSignal = signals.logout();
expect(logoutSignal[Symbol.toStringTag]).toBe('logout');
});
});
Testing State Flows
Unit Testing Flow Handlers
Test your flow logic in isolation:
import { defineFlow, Result, ResultKind } from '@video/state-flow';
describe('UserFlow', () => {
const userState = defineState<{
id: string;
email: string;
loginAttempts: number;
}>()
.name("user")
.variant("guest", true)
.variant("authenticated")
.variant("locked")
.build();
const signals = {
login: defineSignal<{ email: string; password: string }>("login"),
failedLogin: defineSignal("failedLogin")
};
test('should handle successful login', () => {
defineFlow(userState.guest, {
login: (state, signal) => {
// Mock authentication
if (signal.password === 'correct') {
return userState.authenticated({
id: 'user123',
email: signal.email,
loginAttempts: 0
});
}
return Result.reject('Invalid credentials');
}
});
// Test the flow directly - flows are pure functions
const initialState = { id: '', email: '', loginAttempts: 0 };
const loginSignal = signals.login({
email: 'test@example.com',
password: 'correct'
});
// Access the flow handler directly for unit testing
const flowHandlers = getFlowHandlers(userState.guest);
const result = flowHandlers.login(initialState, loginSignal, null);
// Verify result is a new state instance
expect(isState(result)).toBe(true);
const newState = result as StateInstance;
expect(newState.id).toBe('user123');
expect(newState.email).toBe('test@example.com');
});
test('should reject invalid login', () => {
const initialState = { id: '', email: '', loginAttempts: 0 };
const loginSignal = signals.login({
email: 'test@example.com',
password: 'wrong'
});
const flowHandlers = getFlowHandlers(userState.guest);
const result = flowHandlers.login(initialState, loginSignal, null);
expect(result.kind).toBe(ResultKind.Rejected);
expect(result.message).toBe('Invalid credentials');
});
test('should handle account locking', () => {
defineFlow(userState.guest, {
failedLogin: (state) => {
const newAttempts = state.loginAttempts + 1;
if (newAttempts >= 3) {
return userState.locked({ ...state, loginAttempts: newAttempts });
}
return { ...state, loginAttempts: newAttempts };
}
});
// Test progression through failed attempts
const flowHandlers = getFlowHandlers(userState.guest);
const state1 = flowHandlers.failedLogin({ id: '', email: '', loginAttempts: 0 }, signals.failedLogin(), null);
expect((state1 as any).loginAttempts).toBe(1);
const state2 = flowHandlers.failedLogin({ id: '', email: '', loginAttempts: 2 }, signals.failedLogin(), null);
expect(stateVar(state2)).toBe('locked');
});
});
Integration Testing
Testing Full Application Flow
Test complete scenarios with applyFlow
:
import { applyFlow, dispatch, sync } from '@video/state-flow';
describe('User Authentication Flow', () => {
let app: { user: UserProps };
let mockAuthService: AuthService;
beforeEach(() => {
app = {
user: { id: '', email: '', loginAttempts: 0 }
};
mockAuthService = {
authenticate: vi.fn(),
logout: vi.fn()
};
applyFlow(app, [userState], (sm) => {
sm.addEnterHandler(userState.authenticated, async (state) => {
return Result.transition(async () => {
await mockAuthService.authenticate(state.email);
return Result.ok();
}, 1000);
});
sm.addExitHandler(userState.authenticated, (state) => {
mockAuthService.logout();
return Result.ok();
});
});
});
test('should complete full login flow', async () => {
// Mock successful authentication
mockAuthService.authenticate.mockResolvedValue({ token: 'abc123' });
// Dispatch login signal and wait for completion
await dispatch(app, signals.login({
email: 'test@example.com',
password: 'correct'
})).expect(ResultKind.OK).done();
// Verify state change
expect(stateVar(app.user)).toBe('authenticated');
expect(app.user.email).toBe('test@example.com');
// Verify side effect was called
expect(mockAuthService.authenticate).toHaveBeenCalledWith('test@example.com');
});
test('should handle authentication failure', async () => {
// Mock authentication failure
mockAuthService.authenticate.mockRejectedValue(new Error('Auth failed'));
// Expect rejection
await expect(
dispatch(app, signals.login({
email: 'test@example.com',
password: 'wrong'
})).expect(ResultKind.OK).done()
).rejects.toThrow();
expect(stateVar(app.user)).toBe('guest');
});
});
Testing Async Operations
Testing State Transitions with Timeouts
describe('Async State Transitions', () => {
test('should handle timeout in async operations', async () => {
const connectionState = defineState<{ url: string }>()
.name("connection")
.variant("disconnected", true)
.variant("connecting")
.variant("connected")
.build();
const app = { connection: { url: '' } };
applyFlow(app, [connectionState], (sm) => {
sm.addEnterHandler(connectionState.connecting, (state) => {
return Result.transition(async () => {
// Simulate long operation
await new Promise(resolve => setTimeout(resolve, 2000));
return Result.ok();
}, 100); // Short timeout
});
});
// Should timeout and throw error
await expect(
dispatch(app, signals.connect({ url: 'wss://test.com' })).done()
).rejects.toThrow(/timeout/);
});
test('should wait for all transitions with sync()', async () => {
// Dispatch multiple async operations
const promise1 = dispatch(app, signals.action1()).done();
const promise2 = dispatch(app, signals.action2()).done();
// Wait for all to complete using sync
await sync(app);
// Both should be completed
await promise1;
await promise2;
});
});
Testing State Observers
Testing Observer Behavior
describe('State Observers', () => {
test('should call observer on state changes', async () => {
const mockObserver = vi.fn();
const app = { counter: { count: 0 } };
applyFlow(app, [counterState], () => {});
using observer = observe(app, [counterState.active], mockObserver);
// Dispatch state change and wait for completion
await dispatch(app, signals.increment()).done();
expect(mockObserver).toHaveBeenCalledWith(
expect.objectContaining({ count: 1 })
);
});
test('should respect custom comparison function', () => {
const mockObserver = vi.fn();
const app = { user: { id: '123', name: 'Alice', lastSeen: Date.now() } };
using observer = observe(
app,
[userState.active],
mockObserver,
(prev, curr) => prev.name !== curr.name // Only observe name changes
);
// Update lastSeen (should not trigger observer)
await dispatch(app, signals.updateLastSeen({ timestamp: Date.now() })).done();
expect(mockObserver).not.toHaveBeenCalled();
// Update name (should trigger observer)
await dispatch(app, signals.updateName({ name: 'Bob' })).done();
expect(mockObserver).toHaveBeenCalled();
});
});
Testing Error Conditions
Testing Error Scenarios
describe('Error Handling', () => {
test('should handle exceptions in flow handlers', async () => {
defineFlow(userState.authenticated, {
corruptData: () => {
throw new Error('Data corruption detected');
}
});
// Expect error to be thrown
await expect(
dispatch(app, signals.corruptData()).done()
).rejects.toThrow('Data corruption detected');
});
test('should handle exceptions in state handlers', async () => {
applyFlow(app, [userState], (sm) => {
sm.addEnterHandler(userState.authenticated, () => {
throw new Error('Handler failed');
});
});
// Expect error to be thrown
await expect(
dispatch(app, signals.login({ email: 'test@example.com', password: 'correct' })).done()
).rejects.toThrow('Handler failed');
});
});
Framework-Specific Testing
React Component Testing
import { render, fireEvent, waitFor } from '@testing-library/react';
function UserProfile({ app }: { app: MyApp }) {
const user = useStateFlow(app, [userState.authenticated, userState.guest]);
if (!user) return null;
return (
<div>
<span data-testid="user-name">{user.name}</span>
<button
data-testid="logout"
onClick={() => dispatch(app, signals.logout())}
>
Logout
</button>
</div>
);
}
test('should update UI when user state changes', async () => {
const app = createTestApp();
const { getByTestId } = render(<UserProfile app={app} />);
// Initial state
expect(getByTestId('user-name')).toHaveTextContent('Guest');
// Login
await dispatch(app, signals.login({ email: 'test@example.com', password: 'correct' })).done();
await waitFor(() => {
expect(getByTestId('user-name')).toHaveTextContent('test@example.com');
});
// Logout
fireEvent.click(getByTestId('logout'));
await waitFor(() => {
expect(getByTestId('user-name')).toHaveTextContent('Guest');
});
});
Test Utilities
Creating Test Helpers
// test-utils.ts
import { applyFlow } from '@video/state-flow';
export function createTestApp(initialState?: Partial<MyApp>): MyApp {
const app: MyApp = {
user: { id: '', email: '', loginAttempts: 0 },
connection: { status: 'disconnected', retryCount: 0 },
...initialState
};
applyFlow(app, [userState, connectionState], () => {
// Minimal setup for testing
});
return app;
}
export function waitForTransition(app: MyApp): Promise<void> {
return sync(app);
}
export function getStateString(app: MyApp, stateName: keyof MyApp): string {
return app[stateName].toString();
}
// Mock services for testing
export const createMockAuthService = (): AuthService => ({
authenticate: vi.fn().mockResolvedValue({ token: 'test' }),
logout: vi.fn().mockResolvedValue(void 0)
});
Best Practices
Testing Strategies
- Start with unit tests for individual flows and state logic
- Use integration tests for complete user scenarios
- Test error conditions explicitly - don't assume happy paths
- Mock external dependencies but test state transitions
- Use type checking to catch issues at compile time
- Test async operations with proper timeout handling
Common Patterns
// Group related tests by state or feature
describe('MediaPlayer', () => {
describe('PlaybackState', () => {
// Test playback state flows
});
describe('VolumeState', () => {
// Test volume state flows
});
describe('Integration', () => {
// Test state coordination
});
});
// Use descriptive test names
test('should transition to playing state when play signal dispatched from paused state', () => {
// Test implementation
});
// Test both success and failure cases
describe('when user login', () => {
test('should authenticate with valid credentials', () => {});
test('should reject invalid credentials', () => {});
test('should lock account after repeated failures', () => {});
});
This comprehensive testing approach ensures your StateFlow applications are robust, maintainable, and behave predictably under all conditions.