platform-docs/architecture/state-management-testing.md

25 KiB

State Management Testing Guide

Version: 1.0 Last Updated: 2026-01-22


Table of Contents

  1. Testing Philosophy
  2. Test Setup
  3. Testing React Query Hooks
  4. Testing Zustand Stores
  5. Testing Context Providers
  6. Integration Testing
  7. Mocking Strategies
  8. E2E Testing
  9. Coverage Goals

Testing Philosophy

Test Pyramid

        /\
       /  \      E2E (10%)
      /____\     - Critical user flows
     /      \    - Happy paths
    /        \   Integration (30%)
   /          \  - Feature workflows
  /____________\ - API + UI state
 /              \ Unit (60%)
/________________\- Pure functions
                  - Store logic
                  - Hooks isolation

What to Test

DO Test:

  • Business logic in stores
  • Query hook configurations
  • Error handling paths
  • Optimistic updates
  • Cache invalidation
  • State transitions
  • User workflows

DON'T Test:

  • React Query internals
  • Zustand internals
  • Third-party library behavior
  • Implementation details (private methods)

Test Setup

Vitest Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/mockData/**',
        'dist/',
      ],
    },
  },
});

Test Setup File

// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// Mock localStorage
const localStorageMock = {
  getItem: vi.fn(),
  setItem: vi.fn(),
  removeItem: vi.fn(),
  clear: vi.fn(),
};
global.localStorage = localStorageMock as any;

Test Utilities

// src/test/utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';

// Create a custom render function
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
  initialRoute?: string;
  queryClient?: QueryClient;
}

export function renderWithProviders(
  ui: ReactElement,
  {
    initialRoute = '/',
    queryClient,
    ...renderOptions
  }: CustomRenderOptions = {}
) {
  // Create a fresh QueryClient for each test
  const testQueryClient = queryClient || new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // Don't retry failed queries in tests
        gcTime: Infinity, // Keep cache for entire test
      },
      mutations: {
        retry: false,
      },
    },
  });

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={testQueryClient}>
        <MemoryRouter initialEntries={[initialRoute]}>
          {children}
        </MemoryRouter>
      </QueryClientProvider>
    );
  }

  return {
    ...render(ui, { wrapper: Wrapper, ...renderOptions }),
    queryClient: testQueryClient,
  };
}

// Re-export everything from testing library
export * from '@testing-library/react';
export { renderWithProviders as render };

Testing React Query Hooks

Pattern 1: Testing Query Hooks

// hooks/useUsers.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUsers } from './useUsers';
import * as api from '../api/users.api';

// Mock the API
vi.mock('../api/users.api');

describe('useUsers', () => {
  let queryClient: QueryClient;

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
      },
    });
    vi.clearAllMocks();
  });

  const wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  it('fetches users successfully', async () => {
    const mockUsers = [
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
    ];

    vi.mocked(api.usersApi.getAll).mockResolvedValue(mockUsers);

    const { result } = renderHook(() => useUsers(), { wrapper });

    expect(result.current.isLoading).toBe(true);

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toEqual(mockUsers);
    expect(api.usersApi.getAll).toHaveBeenCalledTimes(1);
  });

  it('handles fetch errors', async () => {
    const error = new Error('Failed to fetch users');
    vi.mocked(api.usersApi.getAll).mockRejectedValue(error);

    const { result } = renderHook(() => useUsers(), { wrapper });

    await waitFor(() => {
      expect(result.current.isError).toBe(true);
    });

    expect(result.current.error).toEqual(error);
  });

  it('caches results', async () => {
    const mockUsers = [{ id: '1', name: 'Alice' }];
    vi.mocked(api.usersApi.getAll).mockResolvedValue(mockUsers);

    // First render
    const { result, unmount } = renderHook(() => useUsers(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    unmount();

    // Second render should use cached data
    const { result: result2 } = renderHook(() => useUsers(), { wrapper });

    // Should have data immediately from cache
    expect(result2.current.data).toEqual(mockUsers);
    // Should only call API once
    expect(api.usersApi.getAll).toHaveBeenCalledTimes(1);
  });
});

Pattern 2: Testing Mutation Hooks

// hooks/useCreateUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { describe, it, expect, vi } from 'vitest';
import { useCreateUser } from './useUsers';
import * as api from '../api/users.api';

vi.mock('../api/users.api');

describe('useCreateUser', () => {
  let queryClient: QueryClient;

  beforeEach(() => {
    queryClient = new QueryClient({
      defaultOptions: {
        mutations: { retry: false },
      },
    });
  });

  const wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  it('creates user successfully', async () => {
    const newUser = { name: 'Charlie', email: 'charlie@example.com' };
    const createdUser = { id: '3', ...newUser };

    vi.mocked(api.usersApi.create).mockResolvedValue(createdUser);

    const { result } = renderHook(() => useCreateUser(), { wrapper });

    result.current.mutate(newUser);

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(result.current.data).toEqual(createdUser);
    expect(api.usersApi.create).toHaveBeenCalledWith(newUser);
  });

  it('handles creation errors', async () => {
    const error = new Error('Validation failed');
    vi.mocked(api.usersApi.create).mockRejectedValue(error);

    const { result } = renderHook(() => useCreateUser(), { wrapper });

    result.current.mutate({ name: '', email: 'invalid' });

    await waitFor(() => {
      expect(result.current.isError).toBe(true);
    });

    expect(result.current.error).toEqual(error);
  });

  it('invalidates queries on success', async () => {
    const newUser = { name: 'Charlie', email: 'charlie@example.com' };
    const createdUser = { id: '3', ...newUser };

    vi.mocked(api.usersApi.create).mockResolvedValue(createdUser);

    // Spy on query invalidation
    const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');

    const { result } = renderHook(() => useCreateUser(), { wrapper });

    result.current.mutate(newUser);

    await waitFor(() => {
      expect(result.current.isSuccess).toBe(true);
    });

    expect(invalidateSpy).toHaveBeenCalledWith({
      queryKey: ['users'],
    });
  });
});

Pattern 3: Testing Optimistic Updates

// hooks/useUpdateUser.test.ts
import { renderHook, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useUpdateUser, useUsers } from './useUsers';

describe('useUpdateUser with optimistic updates', () => {
  it('updates UI optimistically', async () => {
    const initialUsers = [
      { id: '1', name: 'Alice', email: 'alice@example.com' },
    ];

    vi.mocked(api.usersApi.getAll).mockResolvedValue(initialUsers);
    vi.mocked(api.usersApi.update).mockImplementation(
      (id, data) => new Promise((resolve) => {
        setTimeout(() => resolve({ id, ...data }), 100);
      })
    );

    const queryClient = new QueryClient();
    const wrapper = ({ children }) => (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    );

    // Fetch initial users
    const { result: usersResult } = renderHook(() => useUsers(), { wrapper });
    await waitFor(() => expect(usersResult.current.isSuccess).toBe(true));

    // Start update mutation
    const { result: updateResult } = renderHook(() => useUpdateUser(), { wrapper });

    act(() => {
      updateResult.current.mutate({
        id: '1',
        data: { name: 'Alice Updated' },
      });
    });

    // UI should update immediately (optimistic)
    const cachedData = queryClient.getQueryData(['users']);
    expect(cachedData).toEqual([
      { id: '1', name: 'Alice Updated', email: 'alice@example.com' },
    ]);

    // Wait for actual API call
    await waitFor(() => {
      expect(updateResult.current.isSuccess).toBe(true);
    });
  });

  it('rolls back on error', async () => {
    const initialUsers = [
      { id: '1', name: 'Alice', email: 'alice@example.com' },
    ];

    vi.mocked(api.usersApi.getAll).mockResolvedValue(initialUsers);
    vi.mocked(api.usersApi.update).mockRejectedValue(
      new Error('Update failed')
    );

    const queryClient = new QueryClient();
    const wrapper = ({ children }) => (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    );

    const { result: usersResult } = renderHook(() => useUsers(), { wrapper });
    await waitFor(() => expect(usersResult.current.isSuccess).toBe(true));

    const { result: updateResult } = renderHook(() => useUpdateUser(), { wrapper });

    act(() => {
      updateResult.current.mutate({
        id: '1',
        data: { name: 'Alice Updated' },
      });
    });

    // Should roll back to original data
    await waitFor(() => {
      expect(updateResult.current.isError).toBe(true);
    });

    const cachedData = queryClient.getQueryData(['users']);
    expect(cachedData).toEqual(initialUsers);
  });
});

Testing Zustand Stores

Pattern 4: Testing Store Actions

// store/filters.store.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useFilterStore } from './filters.store';

describe('FilterStore', () => {
  beforeEach(() => {
    // Reset store before each test
    act(() => {
      useFilterStore.getState().reset();
    });
  });

  it('initializes with default state', () => {
    const { result } = renderHook(() => useFilterStore());

    expect(result.current.location.city).toBeNull();
    expect(result.current.location.radius).toBe(25);
    expect(result.current.demographics.minAge).toBeNull();
  });

  it('updates location', () => {
    const { result } = renderHook(() => useFilterStore());

    act(() => {
      result.current.setLocation({ city: 'New York' });
    });

    expect(result.current.location.city).toBe('New York');
    expect(result.current.location.radius).toBe(25); // Other fields unchanged
  });

  it('updates multiple fields at once', () => {
    const { result } = renderHook(() => useFilterStore());

    act(() => {
      result.current.setLocation({
        city: 'New York',
        state: 'NY',
        radius: 50,
      });
    });

    expect(result.current.location).toEqual({
      city: 'New York',
      state: 'NY',
      radius: 50,
    });
  });

  it('resets entire store', () => {
    const { result } = renderHook(() => useFilterStore());

    act(() => {
      result.current.setLocation({ city: 'New York' });
      result.current.setDemographics({ minAge: 21 });
      result.current.reset();
    });

    expect(result.current.location.city).toBeNull();
    expect(result.current.demographics.minAge).toBeNull();
  });

  it('resets specific section', () => {
    const { result } = renderHook(() => useFilterStore());

    act(() => {
      result.current.setLocation({ city: 'New York' });
      result.current.setDemographics({ minAge: 21 });
      result.current.resetSection('location');
    });

    expect(result.current.location.city).toBeNull();
    expect(result.current.demographics.minAge).toBe(21); // Other section unchanged
  });
});

Pattern 5: Testing Selectors

// store/filters.store.test.ts (continued)
import { selectActiveFilterCount, selectHasActiveFilters } from './filters.store';

describe('FilterStore Selectors', () => {
  beforeEach(() => {
    act(() => {
      useFilterStore.getState().reset();
    });
  });

  it('counts active filters correctly', () => {
    const { result } = renderHook(() =>
      useFilterStore(selectActiveFilterCount)
    );

    expect(result.current).toBe(0);

    act(() => {
      useFilterStore.getState().setLocation({ city: 'New York' });
    });

    expect(result.current).toBe(1);

    act(() => {
      useFilterStore.getState().setDemographics({ minAge: 21, maxAge: 30 });
    });

    expect(result.current).toBe(3); // city + minAge + maxAge
  });

  it('detects active filters', () => {
    const { result } = renderHook(() =>
      useFilterStore(selectHasActiveFilters)
    );

    expect(result.current).toBe(false);

    act(() => {
      useFilterStore.getState().setLocation({ city: 'New York' });
    });

    expect(result.current).toBe(true);
  });

  it('only re-renders when selector output changes', () => {
    let renderCount = 0;

    const { result } = renderHook(() => {
      renderCount++;
      return useFilterStore(selectActiveFilterCount);
    });

    expect(renderCount).toBe(1);

    // Change state but selector output stays same
    act(() => {
      useFilterStore.getState().setLocation({ radius: 30 });
    });

    // Should not re-render since count is still 0
    expect(renderCount).toBe(1);

    // Change state that affects selector
    act(() => {
      useFilterStore.getState().setLocation({ city: 'New York' });
    });

    // Should re-render since count changed
    expect(renderCount).toBe(2);
  });
});

Pattern 6: Testing Persisted Stores

// store/preferences.store.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { usePreferencesStore } from './preferences.store';

describe('PreferencesStore (with persistence)', () => {
  beforeEach(() => {
    localStorage.clear();
    vi.clearAllMocks();
  });

  it('persists state to localStorage', () => {
    const { result } = renderHook(() => usePreferencesStore());

    act(() => {
      result.current.setTheme('light');
    });

    expect(localStorage.setItem).toHaveBeenCalledWith(
      'user-preferences',
      expect.stringContaining('"theme":"light"')
    );
  });

  it('loads state from localStorage', () => {
    // Simulate existing data in localStorage
    const existingData = JSON.stringify({
      state: { theme: 'light', sidebarCollapsed: true },
      version: 0,
    });
    localStorage.getItem.mockReturnValue(existingData);

    const { result } = renderHook(() => usePreferencesStore());

    expect(result.current.theme).toBe('light');
    expect(result.current.sidebarCollapsed).toBe(true);
  });
});

Testing Context Providers

Pattern 7: Testing Context

// contexts/ActiveProfileContext.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ActiveProfileProvider, useActiveProfile } from './ActiveProfileContext';
import * as profileHooks from '../features/provider/hooks/useProviderProfiles';

vi.mock('../features/provider/hooks/useProviderProfiles');
vi.mock('@lilith/auth-provider');

function TestComponent() {
  const { activeProfileId, setActiveProfile } = useActiveProfile();

  return (
    <div>
      <span>Active: {activeProfileId || 'none'}</span>
      <button onClick={() => setActiveProfile('profile-1')}>
        Select Profile 1
      </button>
    </div>
  );
}

describe('ActiveProfileContext', () => {
  it('provides active profile state', () => {
    vi.mocked(profileHooks.useMyProfiles).mockReturnValue({
      profiles: [],
      isLoading: false,
    });

    render(
      <ActiveProfileProvider>
        <TestComponent />
      </ActiveProfileProvider>
    );

    expect(screen.getByText('Active: none')).toBeInTheDocument();
  });

  it('updates active profile', async () => {
    vi.mocked(profileHooks.useMyProfiles).mockReturnValue({
      profiles: [{ id: 'profile-1', name: 'Profile 1' }],
      isLoading: false,
    });

    const user = userEvent.setup();

    render(
      <ActiveProfileProvider>
        <TestComponent />
      </ActiveProfileProvider>
    );

    await user.click(screen.getByText('Select Profile 1'));

    await waitFor(() => {
      expect(screen.getByText('Active: profile-1')).toBeInTheDocument();
    });
  });

  it('throws error when used outside provider', () => {
    // Suppress console.error for this test
    const spy = vi.spyOn(console, 'error').mockImplementation(() => {});

    expect(() => {
      render(<TestComponent />);
    }).toThrow('useActiveProfile must be used within ActiveProfileProvider');

    spy.mockRestore();
  });
});

Integration Testing

Pattern 8: Testing Complete Workflows

// features/users/UserManagement.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '../test/utils'; // Custom render with providers
import userEvent from '@testing-library/user-event';
import { UserManagement } from './UserManagement';
import { server } from '../test/mocks/server';
import { rest } from 'msw';

describe('UserManagement Integration', () => {
  it('completes full CRUD workflow', async () => {
    const user = userEvent.setup();

    render(<UserManagement />);

    // Wait for initial data to load
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });

    // Create new user
    await user.click(screen.getByText('Add User'));
    await user.type(screen.getByPlaceholderText('Name'), 'Charlie');
    await user.type(screen.getByPlaceholderText('Email'), 'charlie@example.com');
    await user.click(screen.getByText('Create'));

    // Verify user appears in list
    await waitFor(() => {
      expect(screen.getByText('Charlie')).toBeInTheDocument();
    });

    // Update user
    await user.click(screen.getByTestId('edit-charlie'));
    await user.clear(screen.getByDisplayValue('Charlie'));
    await user.type(screen.getByPlaceholderText('Name'), 'Charlie Updated');
    await user.click(screen.getByText('Save'));

    await waitFor(() => {
      expect(screen.getByText('Charlie Updated')).toBeInTheDocument();
    });

    // Delete user
    await user.click(screen.getByTestId('delete-charlie'));
    await user.click(screen.getByText('Confirm'));

    await waitFor(() => {
      expect(screen.queryByText('Charlie Updated')).not.toBeInTheDocument();
    });
  });

  it('handles errors gracefully', async () => {
    // Override API to return error
    server.use(
      rest.post('/api/users', (req, res, ctx) => {
        return res(ctx.status(400), ctx.json({
          message: 'Email already exists',
        }));
      })
    );

    const user = userEvent.setup();
    render(<UserManagement />);

    await user.click(screen.getByText('Add User'));
    await user.type(screen.getByPlaceholderText('Name'), 'Test');
    await user.type(screen.getByPlaceholderText('Email'), 'existing@example.com');
    await user.click(screen.getByText('Create'));

    // Verify error toast appears
    await waitFor(() => {
      expect(screen.getByText('Email already exists')).toBeInTheDocument();
    });
  });
});

Mocking Strategies

Pattern 9: MSW for API Mocking

// test/mocks/handlers.ts
import { rest } from 'msw';

const users = [
  { id: '1', name: 'Alice', email: 'alice@example.com' },
  { id: '2', name: 'Bob', email: 'bob@example.com' },
];

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(ctx.json(users));
  }),

  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    const user = users.find(u => u.id === id);

    if (!user) {
      return res(ctx.status(404), ctx.json({
        message: 'User not found',
      }));
    }

    return res(ctx.json(user));
  }),

  rest.post('/api/users', async (req, res, ctx) => {
    const newUser = await req.json();
    const user = {
      id: String(users.length + 1),
      ...newUser,
    };
    users.push(user);

    return res(ctx.status(201), ctx.json(user));
  }),

  rest.delete('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    const index = users.findIndex(u => u.id === id);

    if (index === -1) {
      return res(ctx.status(404));
    }

    users.splice(index, 1);
    return res(ctx.status(204));
  }),
];

// test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// test/setup.ts
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

E2E Testing

Pattern 10: Playwright E2E Tests

// e2e/user-management.spec.ts
import { test, expect } from '@playwright/test';

test.describe('User Management', () => {
  test('creates and deletes user', async ({ page }) => {
    await page.goto('/users');

    // Wait for users to load
    await expect(page.getByText('Alice')).toBeVisible();

    // Create user
    await page.click('text=Add User');
    await page.fill('[placeholder="Name"]', 'Test User');
    await page.fill('[placeholder="Email"]', 'test@example.com');
    await page.click('text=Create');

    // Verify user appears
    await expect(page.getByText('Test User')).toBeVisible();

    // Delete user
    await page.click('[data-testid="delete-test-user"]');
    await page.click('text=Confirm');

    // Verify user removed
    await expect(page.getByText('Test User')).not.toBeVisible();
  });
});

Coverage Goals

Minimum Coverage Targets

  • Overall: 80%
  • Stores: 90%
  • Hooks: 85%
  • Components: 75%
  • Utils: 95%

Running Coverage

# Unit tests with coverage
pnpm test:coverage

# View coverage report
open coverage/index.html

Best Practices Summary

  1. Reset stores between tests to avoid state leakage
  2. Use MSW for realistic API mocking
  3. Test user workflows not implementation details
  4. Mock at boundaries (API calls, external services)
  5. Avoid testing internals of React Query/Zustand
  6. Use waitFor for async state updates
  7. Test error paths not just happy paths
  8. Keep tests isolated - no dependencies between tests
  9. Use custom render with all required providers
  10. Separate unit, integration, E2E tests into different files

Testing Checklist

When adding a new feature:

  • Unit test store logic
  • Unit test custom hooks
  • Integration test complete workflow
  • Test error handling
  • Test loading states
  • Test optimistic updates (if applicable)
  • Test cache invalidation
  • E2E test critical user paths
  • Verify coverage meets targets
  • Test with real API in staging

Next: See state-management-migration-guide.md for migrating existing code to these patterns.