Testing Strategies¶
Comprehensive guide to testing React and Next.js applications.
Testing Philosophy¶
Test Types Pyramid¶
/\
/E2E\ (Few) - Full user flows
/------\
/ API \ (Some) - Integration tests
/----------\
/ Unit Tests \ (Many) - Component/function tests
/--------------\
Principles: 1. Write tests that give confidence 2. Test behavior, not implementation 3. Make tests maintainable 4. Balance coverage vs. speed
Testing Tools Ecosystem¶
Core Stack (2025 Standard)¶
| Tool | Purpose | When to Use |
|---|---|---|
| Vitest | Test runner | Unit & integration tests |
| Testing Library | Component testing | React component tests |
| Playwright | E2E testing | Full user flows |
| MSW | API mocking | Mock server responses |
| Jest | Alternative runner | Legacy projects |
Installation¶
# Vitest + React Testing Library
npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event @vitejs/plugin-react
# Playwright
npm init playwright@latest
# MSW (Mock Service Worker)
npm install -D msw
Unit Testing with Vitest¶
Configuration¶
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: './test/setup.ts',
},
});
test/setup.ts:
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
Basic Component Test¶
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('can be disabled', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Testing Hooks¶
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});
React Testing Library¶
Query Priority¶
Recommended order:
1. getByRole - Most accessible
2. getByLabelText - Form elements
3. getByPlaceholderText - Form fallback
4. getByText - Non-interactive elements
5. getByTestId - Last resort
// ✅ Prefer accessible queries
const button = screen.getByRole('button', { name: /submit/i });
const input = screen.getByLabelText(/username/i);
// ❌ Avoid test IDs when possible
const element = screen.getByTestId('submit-button');
User Interactions¶
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('submits form data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// Type in fields
await user.type(
screen.getByLabelText(/username/i),
'testuser'
);
await user.type(
screen.getByLabelText(/password/i),
'password123'
);
// Submit
await user.click(screen.getByRole('button', { name: /login/i }));
// Verify
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123'
});
});
});
Async Testing¶
import { waitFor, waitForElementToBeRemoved } from '@testing-library/react';
describe('AsyncComponent', () => {
it('loads and displays data', async () => {
render(<UserProfile userId={1} />);
// Wait for loading to disappear
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// Check data appeared
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('handles errors', async () => {
server.use(
rest.get('/api/user/:id', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
});
});
});
Mocking with MSW¶
Setup¶
mocks/handlers.ts:
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
])
);
}),
rest.post('/api/login', async (req, res, ctx) => {
const { username, password } = await req.json();
if (username === 'test' && password === 'password') {
return res(
ctx.status(200),
ctx.json({ token: 'fake-token' })
);
}
return res(
ctx.status(401),
ctx.json({ error: 'Invalid credentials' })
);
}),
];
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';
import { beforeAll, afterEach, afterAll } from 'vitest';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Override Handlers¶
import { server } from '../mocks/server';
import { rest } from 'msw';
describe('Error handling', () => {
it('shows error on failed request', async () => {
// Override default handler
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Next.js Testing¶
Testing Server Components¶
import { render, screen } from '@testing-library/react';
// Mock async component
vi.mock('./data', () => ({
getUsers: vi.fn(() => Promise.resolve([
{ id: 1, name: 'Test User' }
]))
}));
describe('UsersPage', () => {
it('renders user list', async () => {
const UsersPage = (await import('./page')).default;
render(await UsersPage());
expect(screen.getByText('Test User')).toBeInTheDocument();
});
});
Testing Server Actions¶
import { describe, it, expect, vi } from 'vitest';
import { addPost } from './actions';
// Mock Prisma
vi.mock('@/lib/prisma', () => ({
prisma: {
post: {
create: vi.fn((data) => Promise.resolve({ id: 1, ...data }))
}
}
}));
describe('addPost', () => {
it('creates a post', async () => {
const formData = new FormData();
formData.append('title', 'Test Post');
formData.append('content', 'Test Content');
const result = await addPost({}, formData);
expect(result.ok).toBe(true);
expect(prisma.post.create).toHaveBeenCalledWith({
data: {
title: 'Test Post',
content: 'Test Content'
}
});
});
});
Testing Route Handlers¶
import { GET, POST } from './route';
import { NextRequest } from 'next/server';
describe('/api/posts', () => {
it('returns posts', async () => {
const request = new NextRequest('http://localhost:3000/api/posts');
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});
it('creates post', async () => {
const request = new NextRequest('http://localhost:3000/api/posts', {
method: 'POST',
body: JSON.stringify({
title: 'New Post',
content: 'Content'
})
});
const response = await POST(request);
expect(response.status).toBe(201);
});
});
E2E Testing with Playwright¶
Configuration¶
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 13'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Basic E2E Test¶
import { test, expect } from '@playwright/test';
test('user can login', async ({ page }) => {
await page.goto('/login');
// Fill form
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('password123');
// Submit
await page.getByRole('button', { name: /login/i }).click();
// Verify redirect
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome, testuser')).toBeVisible();
});
test('validates form inputs', async ({ page }) => {
await page.goto('/login');
// Submit without filling
await page.getByRole('button', { name: /login/i }).click();
// Check error messages
await expect(page.getByText('Username is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
Page Object Pattern¶
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel('Username');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: /login/i });
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}
// Test using page object
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('testuser', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Test Organization¶
File Structure¶
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.test.tsx
│ │ └── Button.stories.tsx
│ └── Form/
│ ├── Form.tsx
│ └── Form.test.tsx
├── hooks/
│ ├── useCounter.ts
│ └── useCounter.test.ts
└── utils/
├── format.ts
└── format.test.ts
e2e/
├── auth/
│ ├── login.spec.ts
│ └── register.spec.ts
└── blog/
└── create-post.spec.ts
Naming Conventions¶
// Component tests
describe('ComponentName', () => {
it('renders correctly', () => {});
it('handles user interaction', () => {});
it('displays error state', () => {});
});
// Hook tests
describe('useHookName', () => {
it('initializes with default value', () => {});
it('updates state correctly', () => {});
});
// Utility tests
describe('functionName', () => {
it('returns expected output for valid input', () => {});
it('throws error for invalid input', () => {});
});
Testing Best Practices¶
1. Test User Behavior¶
// ❌ Testing implementation
it('sets loading state', () => {
const { result } = renderHook(() => useData());
expect(result.current.loading).toBe(true);
});
// ✅ Testing behavior
it('shows loading indicator while fetching', async () => {
render(<DataComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
});
2. Avoid Testing Library Internals¶
// ❌ Testing React Query internals
it('uses correct query key', () => {
const { result } = renderHook(() => useUsers());
expect(result.current.queryKey).toEqual(['users']);
});
// ✅ Testing actual behavior
it('fetches and displays users', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
3. Keep Tests Simple¶
// ❌ Complex test setup
it('handles complex scenario', async () => {
const user = createMockUser();
const posts = createMockPosts();
const comments = createMockComments();
// ... too much setup
});
// ✅ Simple and focused
it('displays user name', () => {
render(<UserCard user={{ name: 'John' }} />);
expect(screen.getByText('John')).toBeInTheDocument();
});
4. Use Data-TestId Sparingly¶
// ❌ Overusing test IDs
<button data-testid="submit-button">Submit</button>
// ✅ Use semantic queries
<button type="submit">Submit</button>
// Then query by role
screen.getByRole('button', { name: /submit/i });
Coverage Goals¶
What to Aim For¶
| Type | Target | Priority |
|---|---|---|
| Critical paths | 100% | High |
| Business logic | 80%+ | High |
| UI components | 60%+ | Medium |
| Utilities | 90%+ | High |
| E2E flows | Key scenarios | High |
Running Coverage¶
package.json:
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
}
CI/CD Integration¶
GitHub Actions¶
.github/workflows/test.yml:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run test:coverage
- run: npm run test:e2e
- uses: codecov/codecov-action@v3