A great test setup is fast, reliable, and mirrors how users actually use your app. We’ll set up Vitest (fast runner), React Testing Library (test behavior, not internals), and MSW (mock server) — with clear, commented examples for components, hooks, and Redux slices, plus API integration tests.
Philosophy:
- Test the public surface (what users see/do), not implementation details.
- Prefer queries by role/label/text over test IDs.
- Keep unit tests small; add a few integration tests where flows matter.
1) Install test tooling
pnpm add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
pnpm add -D msw whatwg-fetch # MSW for API mocks, fetch polyfill for jsdom
2) Configure Vitest (Vite + jsdom + setup file)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // DOM-like env for component tests
globals: true, // expect(), vi available globally
setupFiles: ['./src/test/setupTests.ts'],
css: true, // allow importing CSS in tests
coverage: {
reporter: ['text', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/stories.*', 'src/main.tsx'],
},
},
});
Add scripts:
// package.json
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:cov": "vitest run --coverage"
}
}
3) Global test setup (RTL + jest-dom + MSW)
// src/test/setupTests.ts
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './testServer';
// Start MSW once for all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Reset handlers between tests (avoid cross-test leakage)
afterEach(() => server.resetHandlers());
// Cleanup after the test suite
afterAll(() => server.close());
Define the MSW server and handlers:
// src/test/testServer.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const handlers = [
// Example API: GET /tasks
http.get('http://localhost:3000/tasks', () => {
return HttpResponse.json({
items: [
{ id: '1', title: 'Buy milk', done: false, created_at: new Date().toISOString() },
],
total: 1,
});
}),
// Toggle
http.patch('http://localhost:3000/tasks/:id/toggle', ({ params }) => {
return HttpResponse.json({ id: params.id, title: 'Buy milk', done: true, created_at: new Date().toISOString() });
}),
];
export const server = setupServer(...handlers);
Adjust the base URL to your
VITE_API_URLduring tests or ensure your axios instance points tohttp://localhost:3000in test env.
4) Test a UI primitive (Button)
// src/components/ui/__tests__/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../Button';
it('renders with text and handles clicks', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button onClick={onClick}>Save</Button>);
// Query by role is preferred for buttons
const btn = await screen.findByRole('button', { name: /save/i });
await user.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('shows loading state and disables interaction', async () => {
render(<Button isLoading>Submit</Button>);
const btn = await screen.findByRole('button', { name: /⏳/ });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('aria-busy', 'true');
});
5) Test a form with validation (Input + React Hook Form + Zod)
// src/features/forms/__tests__/TaskFormView.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TaskFormView } from '@/features/forms/TaskForm';
it('validates title and submits data', async () => {
const user = userEvent.setup();
const submit = vi.fn();
render(<TaskFormView onSubmit={submit} />);
// Submit without typing → expect validation error
await user.click(screen.getByRole('button', { name: /save task/i }));
expect(await screen.findByText(/at least 3 characters/i)).toBeInTheDocument();
await user.type(screen.getByLabelText(/title/i), 'Write tests');
await user.click(screen.getByRole('button', { name: /save task/i }));
expect(submit).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Write tests' }),
);
});
6) Test a data-fetching component with MSW (Tasks page)
// src/pages/tasks/__tests__/Tasks.test.tsx
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import TasksPage from '@/pages/tasks/Tasks';
function renderWithQuery(ui: React.ReactNode) {
const qc = new QueryClient();
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
it('renders tasks from API and toggles', async () => {
renderWithQuery(<TasksPage />);
// Wait for list
const item = await screen.findByText(/buy milk/i);
expect(item).toBeInTheDocument();
// Toggle checkbox (optimistic update + server OK)
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
await (await import('@testing-library/user-event')).default.setup().then(u => u.click(checkbox));
// After mutation it should be checked
expect(checkbox).toBeChecked();
});
7) Test a custom hook (Timer logic example)
// src/hooks/__tests__/useToggle.test.tsx
import { renderHook, act } from '@testing-library/react';
import { useToggle } from '@/hooks/useToggle';
it('toggles boolean value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.value).toBe(false);
act(() => result.current.toggle());
expect(result.current.value).toBe(true);
});
@testing-library/reactre-exports arenderHookhelper when using@testing-library/react-hooksis not available. If needed, install@testing-library/react-hooksor convert the hook usage to a tiny test component.
8) Test a Redux slice (pure reducer)
// src/store/__tests__/uiSlice.test.ts
import reducer, { toggleTheme, setModal, setFilter } from '@/store/uiSlice';
it('toggles theme', () => {
const state = reducer(undefined, { type: 'init' } as any);
const next = reducer(state, toggleTheme());
expect(next.theme).not.toBe(state.theme);
});
it('opens modal and sets filter', () => {
const state = reducer(undefined, { type: 'init' } as any);
const opened = reducer(state, setModal(true));
expect(opened.modalOpen).toBe(true);
const filtered = reducer(opened, setFilter({ search: 'react', showDone: false }));
expect(filtered.filter).toEqual({ search: 'react', showDone: false });
});
9) Mocking error cases with MSW
// src/test/testServer.error.ts (optional alternative handler)
import { http, HttpResponse } from 'msw';
export const errorHandlers = [
http.get('http://localhost:3000/tasks', () => HttpResponse.json({ message: 'Boom' }, { status: 500 })),
];
Swap in a test using server.use(...errorHandlers) to simulate failures and assert error UI.
10) Useful tips
- Avoid implementation details: don’t test internal state; test the UI reactions.
- Act like a user: use
userEvent(click, type) instead of firing raw events. - Stable queries: prefer
getByRole/getByLabelText/findByTextover test IDs. - Keep tests deterministic: mock dates/IDs if needed.
- Coverage: use
pnpm test:covto track; aim for meaningful coverage, not 100%.
11) Optional CI (GitHub Actions)
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm test -- --run
✅ Wrap‑Up
You now have a fast, ergonomic test stack:
- Vitest runner + jsdom
- React Testing Library for behavior-first tests
- MSW for reliable API mocking
- Examples covering components, forms, hooks, Redux slices, and React Query flows
This baseline keeps your app stable as it grows while staying beginner‑friendly and maintainable.
Comments
Post a Comment