| name | testing-patterns |
| description | Testing patterns for React and Next.js applications including Jest, React Testing Library, Playwright E2E, and component testing. Use when writing tests, setting up test infrastructure, testing React components, or implementing E2E tests. |
Testing Patterns
Comprehensive testing patterns for React/Next.js applications.
Jest + React Testing Library
Setup
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
module.exports = createJestConfig(customJestConfig);
import '@testing-library/jest-dom';
Component Testing
Basic component test:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@/components/ui/button';
describe('Button', () => {
it('renders with text', () => {
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 = jest.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();
});
});
Testing async behavior:
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from '@/components/UserProfile';
global.fetch = jest.fn();
describe('UserProfile', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('displays loading state initially', () => {
(fetch as jest.Mock).mockImplementation(
() => new Promise(() => {})
);
render(<UserProfile userId="123" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it('displays user data after loading', async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: 'John Doe', email: 'john@example.com' }),
});
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
it('displays error on fetch failure', async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
});
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Form Testing
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from '@/components/ContactForm';
describe('ContactForm', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello!');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello!',
});
});
});
it('displays validation errors for invalid input', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
it('shows email validation error for invalid email', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={jest.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
});
});
Testing with TanStack Query
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UsersList } from '@/components/UsersList';
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};
describe('UsersList', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('displays users when fetch succeeds', async () => {
(fetch as jest.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
]),
});
render(<UsersList />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText('User 1')).toBeInTheDocument();
expect(screen.getByText('User 2')).toBeInTheDocument();
});
});
});
Mocking Modules
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}));
jest.mock('@/lib/supabase/client', () => ({
createClient: () => ({
from: jest.fn(() => ({
select: jest.fn(() => ({
eq: jest.fn(() => ({
single: jest.fn(() => Promise.resolve({ data: mockData, error: null })),
})),
})),
})),
auth: {
getUser: jest.fn(() => Promise.resolve({ data: { user: mockUser }, error: null })),
},
}),
}));
Playwright E2E Testing
Setup
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,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Basic E2E Test
import { test, expect } from '@playwright/test';
test.describe('Home Page', () => {
test('should display welcome message', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /welcome/i })).toBeVisible();
});
test('should navigate to about page', async ({ page }) => {
await page.goto('/');
await page.click('text=About');
await expect(page).toHaveURL('/about');
});
});
Authentication Flow
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('should log in successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(/welcome back/i)).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
});
});
Page Object Pattern
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: /sign in/i });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('should login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Testing with Authentication State
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await use(page);
},
});
import { test } from './fixtures/auth';
import { expect } from '@playwright/test';
test('should display user dashboard', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
await expect(authenticatedPage.getByText(/your dashboard/i)).toBeVisible();
});
Best Practices
What to Test
Unit tests (Jest + RTL):
- Component rendering
- User interactions
- Form validation
- Utility functions
- Custom hooks
Integration tests:
- Component composition
- Data fetching behavior
- State management
E2E tests (Playwright):
- Critical user flows
- Authentication
- Forms with backend
- Cross-page navigation
Testing Priorities
- Always test: Auth flows, data mutations, critical business logic
- Usually test: Form validation, error states, loading states
- Sometimes test: Static content, styling, animations
Query Priorities (RTL)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email/i)
screen.getByPlaceholderText(/search/i)
screen.getByText(/welcome/i)
screen.getByAltText(/profile/i)
screen.getByTitle(/close/i)
screen.getByTestId('custom-element')