React and Next.js testing patterns and best practices. Use when writing tests, setting up testing infrastructure, or implementing test strategies.
Comprehensive testing strategies for React and Next.js applications.
/\
/E2E\ - Few tests, slow, expensive
/------\
/ Integration \ - Medium number
/--------------\
/ Unit Tests \ - Most tests, fast, cheap
/--------------------\
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('increments count when increment button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByRole('button', { name: /increment/i });
const countDisplay = screen.getByTestId('count');
expect(countDisplay).toHaveTextContent('0');
await user.click(incrementButton);
expect(countDisplay).toHaveTextContent('1');
});
it('decrements count when decrement button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialValue={5} />);
const decrementButton = screen.getByRole('button', { name: /decrement/i });
const countDisplay = screen.getByTestId('count');
await user.click(decrementButton);
expect(countDisplay).toHaveTextContent('4');
});
});
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
it('displays validation errors for invalid email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import { fetchUser } from '@/api/users';
vi.mock('@/api/users');
describe('UserProfile', () => {
it('shows loading state initially', () => {
vi.mocked(fetchUser).mockImplementation(() => new Promise(() => {}));
render(<UserProfile userId="1" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data after loading', async () => {
const mockUser = { id: '1', name: 'John Doe', email: 'john@example.com' };
vi.mocked(fetchUser).mockResolvedValue(mockUser);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(mockUser.name)).toBeInTheDocument();
});
expect(screen.getByText(mockUser.email)).toBeInTheDocument();
});
it('displays error message on fetch failure', async () => {
vi.mocked(fetchUser).mockRejectedValue(new Error('Failed to fetch'));
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
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);
});
});
// useFetch.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
vi.mock('@/api/fetch');
describe('useFetch', () => {
it('fetches data successfully', async () => {
const mockData = { id: 1, name: 'Test' };
vi.mocked(fetch).mockResolvedValue(mockData);
const { result } = renderHook(() => useFetch('/api/test'));
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
it('handles fetch errors', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Fetch failed'));
const { result } = renderHook(() => useFetch('/api/test'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeTruthy();
});
});
// TodoApp.integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TodoApp } from './TodoApp';
import { saveTodo } from '@/api/todos';
vi.mock('@/api/todos');
describe('TodoApp Integration', () => {
it('adds new todo and displays in list', async () => {
const user = userEvent.setup();
vi.mocked(saveTodo).mockResolvedValue({ id: '1', text: 'New todo', completed: false });
render(<TodoApp />);
const input = screen.getByPlaceholderText(/add a todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'New todo');
await user.click(addButton);
await waitFor(() => {
expect(screen.getByText('New todo')).toBeInTheDocument();
});
});
it('toggles todo completion', async () => {
const user = userEvent.setup();
render(<TodoApp initialTodos={[{ id: '1', text: 'Test', completed: false }]} />);
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).toBeChecked();
});
});
});
// ThemeProvider.test.tsx
import { render, screen } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeContext';
describe('ThemeProvider', () => {
it('provides theme to children', () => {
function TestComponent() {
const { theme } = useTheme();
return <div>{theme}</div>;
}
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByText('light')).toBeInTheDocument();
});
it('toggles theme', async () => {
function TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<span>{theme}</span>
<button onClick={toggleTheme}>Toggle</button>
</div>
);
}
const user = userEvent.setup();
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
await user.click(screen.getByRole('button'));
expect(screen.getByText('dark')).toBeInTheDocument();
});
});
// Use e2e testing for Server Components
// e2e/blog.spec.ts (Playwright)
import { test, expect } from '@playwright/test';
test('blog page displays posts', async ({ page }) => {
await page.goto('/blog');
await expect(page.locator('h1')).toContainText('Blog');
await expect(page.locator('article')).toHaveCountGreaterThan(0);
});
test('can navigate to blog post', async ({ page }) => {
await page.goto('/blog');
await page.click('article:first-child a');
await expect(page).toHaveURL(/\/blog\/[\w-]+/);
await expect(page.locator('article h1')).toBeVisible();
});
// app/actions/__tests__/createPost.test.ts
import { createPost } from '../createPost';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
vi.mock('@/lib/prisma');
vi.mock('next/cache');
describe('createPost Server Action', () => {
it('creates post and revalidates', async () => {
const mockPost = { id: '1', title: 'Test', slug: 'test' };
vi.mocked(prisma.post.create).mockResolvedValue(mockPost);
const formData = new FormData();
formData.set('title', 'Test');
formData.set('content', 'Content');
const result = await createPost(formData);
expect(prisma.post.create).toHaveBeenCalledWith({
data: expect.objectContaining({
title: 'Test',
content: 'Content'
})
});
expect(revalidatePath).toHaveBeenCalled();
});
});
// app/api/posts/route.test.ts
import { POST } from '../route';
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
vi.mock('@/lib/prisma');
describe('/api/posts POST', () => {
it('creates new post', async () => {
const mockPost = { id: '1', title: 'Test', content: 'Content' };
vi.mocked(prisma.post.create).mockResolvedValue(mockPost);
const request = new NextRequest('http://localhost/api/posts', {
method: 'POST',
body: JSON.stringify({ title: 'Test', content: 'Content' })
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data).toEqual(mockPost);
});
it('returns 400 for invalid data', async () => {
const request = new NextRequest('http://localhost/api/posts', {
method: 'POST',
body: JSON.stringify({ title: '' })
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});
// pages/LoginPage.ts
import { Page, expect } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async fillForm(email: string, password: string) {
await this.page.fill('[name="email"]', email);
await this.page.fill('[name="password"]', password);
}
async submit() {
await this.page.click('button[type="submit"]');
}
async assertErrorMessage(message: string) {
await expect(this.page.locator('.error')).toContainText(message);
}
}
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test.describe('Authentication', () => {
test('logs in with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillForm('user@example.com', 'password123');
await loginPage.submit();
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('displays error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.fillForm('user@example.com', 'wrongpassword');
await loginPage.submit();
await loginPage.assertErrorMessage('Invalid credentials');
await expect(page).toHaveURL('/login');
});
});
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test';
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
// Full page screenshot
await expect(page).toHaveScreenshot('homepage.png');
// Element screenshot
const hero = page.locator('.hero');
await expect(hero).toHaveScreenshot('hero.png');
});
test('blog post matches snapshot', async ({ page }) => {
await page.goto('/blog/my-post');
// Wait for images to load
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('blog-post.png', {
fullPage: true
});
});
// vi.mock for Vitest
import { vi } from 'vitest';
import { fetchUsers } from '@/api/users';
vi.mock('@/api/users', () => ({
fetchUsers: vi.fn()
}));
describe('UserList', () => {
it('displays users', async () => {
const mockUsers = [
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' }
];
vi.mocked(fetchUsers).mockResolvedValue(mockUsers);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
expect(screen.getByText('Jane')).toBeInTheDocument();
});
});
});
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity
}
}
});
}
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
// mocks/handlers.ts
import { rest } from 'msw';
import { setupServer } from 'msw/node';
export const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' }
])
);
}),
rest.post('/api/users', (req, res, ctx) => {
return res(
ctx.status(201),
ctx.json({ id: '3', name: 'New User' })
);
})
);
// setupTests.ts
import { server } from './mocks/handlers';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/'
]
}
}
});
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
transform: {
'^.+\\.(ts|tsx)$': ['@swc/jest', {
jsc: {
transform: {
react: {
runtime: 'automatic'
}
}
}
}]
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/test/**'
]
};
// test/utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const AllProviders = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) =>
render(ui, { wrapper: AllProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };
// test/builders.ts
export const buildUser = (overrides = {}) => ({
id: '1',
name: 'John Doe',
email: 'john@example.com',
...overrides
});
export const buildPost = (overrides = {}) => ({
id: '1',
title: 'Test Post',
content: 'Test content',
...overrides
});
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer