Skip to content

Latest commit

 

History

History
184 lines (137 loc) · 4.09 KB

File metadata and controls

184 lines (137 loc) · 4.09 KB

Testing

Testing patterns using Vitest and React Testing Library.


Stack

Tool Purpose
Vitest Test runner (Vite-native, fast, Jest-compatible API)
React Testing Library Component testing (behavior-focused, not implementation)
jsdom Browser environment for tests
@testing-library/user-event Simulates real user interactions

Commands

npm test              # Watch mode (re-runs on file changes)
npm run test:run      # Single run (CI-friendly)
npm run test:coverage # Coverage report

Test Utilities

renderWithProviders

Wraps components with all necessary providers (Router, Theme, Toast):

// src/test-utils/render.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from '../contexts/ThemeContext';
import { ToastProvider } from '../contexts/ToastContext';

export function renderWithProviders(ui, options = {}) {
  const { route = '/', ...renderOptions } = options;
  return render(ui, {
    wrapper: ({ children }) => (
      <MemoryRouter initialEntries={[route]}>
        <ThemeProvider>
          <ToastProvider>
            {children}
          </ToastProvider>
        </ThemeProvider>
      </MemoryRouter>
    ),
    ...renderOptions,
  });
}

Usage

import { renderWithProviders } from '../test-utils/render';

test('renders feature page', () => {
  renderWithProviders(<CounterPage />);
  expect(screen.getByText('Counter')).toBeInTheDocument();
});

Testing Patterns

Test User Behavior, Not Implementation

// Good -- tests what the user sees and does
test('increments counter when + button is clicked', async () => {
  renderWithProviders(<CounterPage />);
  await userEvent.click(screen.getByText('+'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

// Bad -- tests implementation details
test('calls setCount with incremented value', () => {
  const setCount = vi.fn();
  // ... testing internal hook calls
});

Testing Hooks Directly

For complex logic, test the hook without rendering UI:

import { renderHook, act } from '@testing-library/react';
import { useCounter } from '../hooks/useCounter';

test('increments and decrements', () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);

  act(() => result.current.decrement());
  expect(result.current.count).toBe(0);
});

Testing with localStorage

The test setup clears localStorage before each test:

beforeEach(() => {
  localStorage.clear();
});

test('persists value to localStorage', () => {
  const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
  act(() => result.current[1]('new value'));
  expect(JSON.parse(localStorage.getItem('test-key'))).toBe('new value');
});

Testing Async Operations

test('loads data asynchronously', async () => {
  renderWithProviders(<MyFeaturePage />);

  // Wait for loading to complete
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  await waitFor(() => {
    expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
  });

  expect(screen.getByText('Data loaded')).toBeInTheDocument();
});

Common Gotchas

Fake Timers

When testing features that use setInterval or setTimeout:

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test('updates every second', () => {
  renderWithProviders(<TimerFeature />);
  act(() => vi.advanceTimersByTime(1000));
  expect(screen.getByText('1s')).toBeInTheDocument();
});

Clipboard API

The Clipboard API requires a secure context. The test setup mocks it:

// In setup.js
Object.assign(navigator, {
  clipboard: {
    writeText: vi.fn().mockResolvedValue(undefined),
    readText: vi.fn().mockResolvedValue(''),
  },
});

ResizeObserver

Some components use ResizeObserver. Mock it in setup:

global.ResizeObserver = class {
  observe() {}
  unobserve() {}
  disconnect() {}
};