flowchart TD
subgraph Trophy["🏆 Testing Trophy"]
E2E["E2E Tests<br/>Few, critical paths"]
INT["Integration Tests<br/>Most valuable"]
UNIT["Unit Tests<br/>Complex logic only"]
STATIC["Static Analysis<br/>TypeScript, ESLint"]
end
E2E --> INT
INT --> UNIT
UNIT --> STATIC
style INT fill:#10b981
Testing Strategies for React Applications
Testing isn’t just about finding bugs—it’s about confidence. It allows you to refactor legacy code, upgrade dependencies, and ship features without the constant fear of breaking something.
In the React ecosystem, we follow the “Testing Trophy” philosophy: Write tests. Not too many. Mostly integration.
The Stack
| Tool | Purpose |
|---|---|
| Vitest | Fast unit/integration test runner |
| React Testing Library | Test components like users |
| MSW | Mock API calls |
| Playwright | E2E browser tests |
| TypeScript | Static type checking |
npm install -D vitest jsdom @testing-library/react @testing-library/user-event mswConfiguration
Vitest Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules', 'src/test'],
},
},
});// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';
// MSW setup
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
});
afterAll(() => server.close());Writing Your First Test
The Component
// src/components/Counter.tsx
import { useState } from 'react';
export function Counter({ initialCount = 0 }: { initialCount?: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p data-testid="count">Count: {count}</p>
<button onClick={() => setCount((c) => c - 1)}>Decrement</button>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
The Test
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('renders initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByTestId('count')).toHaveTextContent('Count: 5');
});
it('increments when clicking increment button', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
});
it('decrements when clicking decrement button', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByRole('button', { name: /decrement/i }));
expect(screen.getByTestId('count')).toHaveTextContent('Count: 4');
});
it('resets to zero', async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
await user.click(screen.getByRole('button', { name: /reset/i }));
expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
});
});
Query Priority
React Testing Library encourages querying by accessibility:
// ✅ Best: By role (most accessible)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// ✅ Good: By label
screen.getByLabelText(/password/i);
screen.getByPlaceholderText(/search/i);
screen.getByText(/welcome/i);
// ⚠️ Okay: By test ID (when no semantic option)
screen.getByTestId('loading-spinner');
// ❌ Avoid: Implementation details
document.querySelector('.btn-primary');
container.querySelector('#submit-btn');
Testing Async Components
// src/components/UserProfile.tsx
import { useQuery } from '@tanstack/react-query';
export function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading user</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserProfile } from './UserProfile';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('UserProfile', () => {
it('shows loading state initially', () => {
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data when loaded', async () => {
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByRole('heading')).toHaveTextContent('John Doe');
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
});
Mocking API Calls with MSW
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'John Doe',
email: 'john@example.com',
});
}),
http.post('/api/projects', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: '123', ...body },
{ status: 201 }
);
}),
];// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);Override Handlers in Tests
import { server } from '../test/mocks/server';
import { http, HttpResponse } from 'msw';
it('shows error when API fails', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ message: 'User not found' },
{ status: 404 }
);
})
);
render(<UserProfile userId="999" />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
Testing Custom Hooks
// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}// src/hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns initial value when no stored value exists', () => {
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
expect(result.current[0]).toBe('light');
});
it('returns stored value when it exists', () => {
localStorage.setItem('theme', JSON.stringify('dark'));
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
expect(result.current[0]).toBe('dark');
});
it('updates localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('theme', 'light'));
act(() => {
result.current[1]('dark');
});
expect(result.current[0]).toBe('dark');
expect(localStorage.getItem('theme')).toBe('"dark"');
});
});
Testing Forms
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits with valid data', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('shows validation errors', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
});
});
Test Organization
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── index.ts
├── hooks/
│ ├── useAuth.ts
│ └── useAuth.test.ts
├── test/
│ ├── mocks/
│ │ ├── handlers.ts
│ │ └── server.ts
│ ├── setup.ts
│ └── utils.tsx # Custom render, providers
Coverage Goals
| Category | Target |
|---|---|
| Critical paths | 100% |
| Business logic | 90%+ |
| UI components | 70-80% |
| Utilities | 100% |
# Run tests with coverage
npm test -- --coverage
TipTesting Philosophy
- Test behavior, not implementation — Query like a user
- Prefer integration tests — They catch more bugs per test
- Mock at the network layer — MSW over mocking fetch
- Keep tests fast — Parallel, in-memory, no real API calls
- Test the error cases — Happy paths are obvious
Testing is an investment. By focusing on user interactions rather than implementation details, your tests become resilient to refactoring and provide long-term value.