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

  1. Start with unit tests for individual flows and state logic
  2. Use integration tests for complete user scenarios
  3. Test error conditions explicitly - don't assume happy paths
  4. Mock external dependencies but test state transitions
  5. Use type checking to catch issues at compile time
  6. 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.

Previous
Media player