Testing Patterns¶
Vitest configuration, React Testing Library patterns, and MSW mock handlers
Key Files¶
frontend/vite.config.ts:182-230- Vitest configurationfrontend/src/test/setup.ts:1-177- Test environment setupfrontend/src/test/common-mocks.ts:1-168- Shared mock utilitiesfrontend/src/mocks/handlers.ts:1-150- MSW mock handlersfrontend/src/test-utils/renderWithProviders.tsx:1-80- Test render helpers
Overview¶
The frontend testing infrastructure uses Vitest for unit and integration tests, React Testing Library for component testing, and MSW (Mock Service Worker) for API mocking. The test setup emphasizes memory safety, proper cleanup, and maintainability through standardized patterns.
Test Configuration¶
Vitest Configuration¶
From frontend/vite.config.ts:182-236:
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
exclude: ['**/node_modules/**', '**/dist/**', 'tests/e2e/**', 'tests/contract/**'],
// Fork-based parallelization for memory isolation
pool: 'forks',
fileParallelism: false, // Sequential within shards
isolate: true, // Restart worker per file
// Timeouts
testTimeout: 30000,
hookTimeout: 30000,
teardownTimeout: 3000,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
statements: 83,
branches: 77,
functions: 81,
lines: 84,
},
},
},
Coverage Thresholds¶
| Metric | Threshold | CI Enforcement |
|---|---|---|
| Statements | 83% | Gate |
| Branches | 77% | Gate |
| Functions | 81% | Gate |
| Lines | 84% | Gate |
Test Setup¶
Global Setup (frontend/src/test/setup.ts)¶
// frontend/src/test/setup.ts:1-24
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { resetCounter } from './factories';
import { server } from '../mocks/server';
// Re-export mock utilities for test files
export {
createRouterMock,
createApiMock,
createWebSocketMock,
testQueryClientOptions,
FAST_TIMEOUT,
STANDARD_TIMEOUT,
} from './common-mocks';
Browser API Mocks¶
// frontend/src/test/setup.ts:44-88
beforeAll(() => {
// Mock ResizeObserver (required by Headless UI)
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Mock matchMedia (required for responsive components)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver (required for infinite scroll)
globalThis.IntersectionObserver = class IntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
};
// Start MSW server
server.listen({ onUnhandledRequest: 'bypass' });
});
Cleanup After Each Test¶
// frontend/src/test/setup.ts:119-154
afterEach(() => {
// Clean up React Testing Library
cleanup();
// Clear localStorage
localStorage.clear();
// Reset MSW handlers
server.resetHandlers();
// Clear all mocks
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
// Reset factory counter
resetCounter();
// Force garbage collection if available
if (typeof globalThis.gc === 'function') {
globalThis.gc();
}
});
Mock Utilities¶
Router Mock¶
// frontend/src/test/common-mocks.ts:26-36
export const createRouterMock = (mockNavigate = vi.fn()) => ({
useNavigate: () => mockNavigate,
useLocation: () => ({ pathname: '/', search: '', hash: '', state: null }),
useParams: () => ({}),
BrowserRouter: ({ children }) => children,
Routes: ({ children }) => children,
Route: ({ element }) => element,
Link: ({ children }) => children,
NavLink: ({ children }) => children,
Outlet: () => null,
});
API Mock¶
// frontend/src/test/common-mocks.ts:54-63
export const createApiMock = (overrides = {}) => ({
fetchCameras: vi.fn().mockResolvedValue([]),
fetchEvents: vi.fn().mockResolvedValue([]),
fetchAlerts: vi.fn().mockResolvedValue([]),
fetchDetections: vi.fn().mockResolvedValue([]),
fetchEntities: vi.fn().mockResolvedValue([]),
fetchSystemHealth: vi.fn().mockResolvedValue({ status: 'healthy' }),
fetchGpuStats: vi.fn().mockResolvedValue({ utilization: 0 }),
...overrides,
});
WebSocket Mock¶
// frontend/src/test/common-mocks.ts:81-90
export const createWebSocketMock = (overrides = {}) => ({
connect: vi.fn(),
disconnect: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
send: vi.fn(),
on: vi.fn(),
off: vi.fn(),
...overrides,
});
Query Client Options¶
// frontend/src/test/common-mocks.ts:98-109
export const testQueryClientOptions = {
defaultOptions: {
queries: {
retry: false, // Disable retries for faster tests
staleTime: 0,
gcTime: 0,
},
mutations: {
retry: false,
},
},
};
Component Testing Patterns¶
renderWithProviders¶
Custom render function that wraps components with necessary providers:
// frontend/src/test-utils/renderWithProviders.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0, gcTime: 0 },
mutations: { retry: false },
},
});
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
);
return {
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...options }),
};
}
Component Test Structure¶
// frontend/src/components/dashboard/DashboardPage.test.tsx:276-290
describe('DashboardPage', () => {
const mockCameras = [/* test data */];
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock implementations
(api.fetchCameras as Mock).mockResolvedValue(mockCameras);
(useEventStreamHook.useEventStream as Mock).mockReturnValue({
events: [],
isConnected: true,
});
});
describe('Loading State', () => {
it('renders loading skeletons while fetching data', () => {
(api.fetchCameras as Mock).mockImplementation(() => new Promise(() => {}));
renderWithProviders(<DashboardPage />);
expect(screen.getAllByTestId('stats-card-skeleton')).toHaveLength(4);
});
});
describe('Successful Render', () => {
it('renders dashboard header', async () => {
renderWithProviders(<DashboardPage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /security dashboard/i }))
.toBeInTheDocument();
});
});
});
});
Mocking Child Components¶
// frontend/src/components/dashboard/DashboardPage.test.tsx:42-67
vi.mock('./StatsRow', () => ({
default: ({
activeCameras,
eventsToday,
currentRiskScore,
systemStatus,
}: Props) => (
<div
data-testid="stats-row"
data-active-cameras={activeCameras}
data-events-today={eventsToday}
data-risk-score={currentRiskScore}
data-system-status={systemStatus}
>
Stats Row
</div>
),
}));
Mocking Hooks¶
// frontend/src/components/dashboard/DashboardPage.test.tsx:28-39
vi.mock('../../hooks/useEventStream', () => ({
useEventStream: vi.fn(),
}));
// In test setup
(useEventStreamHook.useEventStream as Mock).mockReturnValue({
events: mockWsEvents,
isConnected: true,
latestEvent: mockWsEvents[0],
clearEvents: vi.fn(),
});
Testing Hooks¶
Hook Test Pattern¶
import { renderHook, waitFor } from '@testing-library/react';
import { useEventStream } from './useEventStream';
// Wrapper with providers
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('useEventStream', () => {
it('returns initial state', () => {
const { result } = renderHook(() => useEventStream(), { wrapper });
expect(result.current.events).toEqual([]);
expect(result.current.isConnected).toBe(false);
});
it('updates state on WebSocket message', async () => {
const { result } = renderHook(() => useEventStream(), { wrapper });
// Trigger WebSocket message
act(() => {
mockWebSocket.emit('message', { type: 'event', data: mockEvent });
});
await waitFor(() => {
expect(result.current.events).toHaveLength(1);
});
});
});
MSW (Mock Service Worker)¶
Handler Setup¶
// frontend/src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Health endpoint
http.get('/api/health', () => {
return HttpResponse.json({
status: 'healthy',
services: { database: 'ok', redis: 'ok', gpu: 'ok' },
});
}),
// Cameras endpoint
http.get('/api/cameras', () => {
return HttpResponse.json([
{ id: 'cam-1', name: 'Front Door', status: 'online' },
{ id: 'cam-2', name: 'Back Yard', status: 'online' },
]);
}),
// Events with pagination
http.get('/api/events', ({ request }) => {
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
return HttpResponse.json({
items: mockEvents.slice(0, limit),
pagination: { total: mockEvents.length, limit, offset: 0 },
});
}),
];
Per-Test Handler Override¶
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
it('handles API error gracefully', async () => {
// Override handler for this test only
server.use(
http.get('/api/cameras', () => {
return HttpResponse.json(
{ error: 'Server error' },
{ status: 500 }
);
})
);
renderWithProviders(<CameraList />);
await waitFor(() => {
expect(screen.getByText(/error loading cameras/i)).toBeInTheDocument();
});
});
Testing User Interactions¶
userEvent Setup¶
// Using userEvent from renderWithProviders
it('navigates on camera click', async () => {
const { user } = renderWithProviders(<DashboardPage />);
await waitFor(() => {
expect(screen.getByTestId('camera-grid')).toBeInTheDocument();
});
// Click camera card
const cameraButton = screen.getByRole('button', { name: 'Front Door' });
await user.click(cameraButton);
expect(mockNavigate).toHaveBeenCalledWith('/timeline?camera=cam-1');
});
Keyboard Interaction¶
it('opens command palette with Cmd+K', async () => {
const { user } = renderWithProviders(<App />);
await user.keyboard('{Meta>}k{/Meta}');
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
Async Testing Patterns¶
waitFor Usage¶
// Wait for element to appear
await waitFor(() => {
expect(screen.getByTestId('stats-row')).toBeInTheDocument();
});
// Wait with custom timeout
await waitFor(() => expect(screen.getByText('Loaded')).toBeInTheDocument(), { timeout: 3000 });
// Wait for element to disappear
await waitFor(() => {
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
findBy Queries¶
// findBy = getBy + waitFor
const header = await screen.findByRole('heading', { name: /dashboard/i });
expect(header).toBeInTheDocument();
Timeout Constants¶
// frontend/src/test/common-mocks.ts:152-168
// Fast timeout for fully mocked components
export const FAST_TIMEOUT = { timeout: 300 };
// Standard timeout for real async operations
export const STANDARD_TIMEOUT = { timeout: 1000 };
Best Practices¶
Test Organization¶
Component.test.tsx
describe('ComponentName')
describe('Loading State')
describe('Error State')
describe('Successful Render')
describe('User Interactions')
describe('Edge Cases')
Naming Conventions¶
- Test files:
*.test.tsxor*.test.ts - Test IDs:
data-testid="component-name"ordata-testid="component-name-action" - Mock files:
*.mock.ts
Avoiding Common Pitfalls¶
- Always clean up: Use
afterEachcleanup - Reset mocks: Call
vi.clearAllMocks()between tests - Avoid act warnings: Use
userEventinstead offireEvent - Memory management: Use fork-based pool, not threads
- Deterministic tests: Mock timers and dates when needed
Testing Accessibility¶
it('has accessible heading structure', async () => {
renderWithProviders(<DashboardPage />);
const h1 = await screen.findByRole('heading', { level: 1 });
expect(h1).toHaveTextContent('Security Dashboard');
const h2s = screen.getAllByRole('heading', { level: 2 });
expect(h2s.length).toBeGreaterThan(0);
});
it('has accessible labels for interactive elements', () => {
renderWithProviders(<CameraCard camera={mockCamera} />);
const button = screen.getByRole('button');
expect(button).toHaveAccessibleName();
});
Running Tests¶
# Run all tests
cd frontend && npm test
# Run with coverage
cd frontend && npm test -- --coverage
# Run specific file
cd frontend && npm test -- DashboardPage
# Run in watch mode
cd frontend && npm test -- --watch
# Run with verbose output
cd frontend && npm test -- --reporter=verbose
Related Documentation¶
- Custom Hooks - Hook testing patterns
- Component Hierarchy - Component structure
- Testing Guide - Project-wide testing docs
Last updated: 2026-01-24 - Initial testing patterns documentation for NEM-3462