25 KiB
25 KiB
State Management Testing Guide
Version: 1.0 Last Updated: 2026-01-22
Table of Contents
- Testing Philosophy
- Test Setup
- Testing React Query Hooks
- Testing Zustand Stores
- Testing Context Providers
- Integration Testing
- Mocking Strategies
- E2E Testing
- 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
- Reset stores between tests to avoid state leakage
- Use MSW for realistic API mocking
- Test user workflows not implementation details
- Mock at boundaries (API calls, external services)
- Avoid testing internals of React Query/Zustand
- Use waitFor for async state updates
- Test error paths not just happy paths
- Keep tests isolated - no dependencies between tests
- Use custom render with all required providers
- 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.