Testing strategy for Next.js is crucial for ensuring application quality and stability. Next.js applications need to test multiple layers including components, pages, API routes, and data fetching logic.
Testing Tool Selection
1. Main Testing Frameworks
javascript// package.json { "devDependencies": { "@testing-library/react": "^14.0.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/user-event": "^14.0.0", "jest": "^29.0.0", "jest-environment-jsdom": "^29.0.0", "@playwright/test": "^1.40.0", "msw": "^2.0.0", "vitest": "^1.0.0", "@vitejs/plugin-react": "^4.0.0" } }
2. Jest Configuration
javascript// jest.config.js const nextJest = require('next/jest') const createJestConfig = nextJest({ dir: './', }) const customJestConfig = { setupFilesAfterEnv: ['<rootDir>/jest.setup.js'], testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { '^@/(.*)$': '<rootDir>/$1', }, collectCoverageFrom: [ 'app/**/*.{js,jsx,ts,tsx}', 'components/**/*.{js,jsx,ts,tsx}', 'lib/**/*.{js,jsx,ts,tsx}', '!**/*.d.ts', '!**/node_modules/**', ], testMatch: [ '**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)' ], } module.exports = createJestConfig(customJestConfig) // jest.setup.js import '@testing-library/jest-dom'
Component Testing
1. Basic Component Testing
javascript// components/__tests__/Button.test.js import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import Button from '../Button' describe('Button Component', () => { it('renders button with text', () => { render(<Button>Click me</Button>) expect(screen.getByText('Click me')).toBeInTheDocument() }) it('calls onClick handler when clicked', async () => { const user = userEvent.setup() const handleClick = jest.fn() render(<Button onClick={handleClick}>Click me</Button>) await user.click(screen.getByText('Click me')) expect(handleClick).toHaveBeenCalledTimes(1) }) it('is disabled when disabled prop is true', () => { render(<Button disabled>Click me</Button>) expect(screen.getByRole('button')).toBeDisabled() }) it('applies correct variant styles', () => { const { rerender } = render(<Button variant="primary">Button</Button>) expect(screen.getByRole('button')).toHaveClass('btn-primary') rerender(<Button variant="secondary">Button</Button>) expect(screen.getByRole('button')).toHaveClass('btn-secondary') }) })
2. Form Component Testing
javascript// components/__tests__/LoginForm.test.js import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import LoginForm from '../LoginForm' describe('LoginForm Component', () => { it('renders email and password inputs', () => { render(<LoginForm />) expect(screen.getByLabelText(/email/i)).toBeInTheDocument() expect(screen.getByLabelText(/password/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument() }) it('shows validation errors for empty fields', async () => { const user = userEvent.setup() render(<LoginForm />) const submitButton = screen.getByRole('button', { name: /login/i }) await user.click(submitButton) await waitFor(() => { expect(screen.getByText(/email is required/i)).toBeInTheDocument() expect(screen.getByText(/password is required/i)).toBeInTheDocument() }) }) it('submits form with valid data', async () => { const user = userEvent.setup() const handleSubmit = jest.fn() render(<LoginForm onSubmit={handleSubmit} />) 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: /login/i })) await waitFor(() => { expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123' }) }) }) })
3. Component Testing with API Calls
javascript// components/__tests__/UserList.test.js import { render, screen, waitFor } from '@testing-library/react' import { rest } from 'msw' import { setupServer } from 'msw/node' import UserList from '../UserList' const server = setupServer( rest.get('/api/users', (req, res, ctx) => { return res( ctx.json([ { id: 1, name: 'John Doe', email: 'john@example.com' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com' } ]) ) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) describe('UserList Component', () => { it('displays loading state initially', () => { render(<UserList />) expect(screen.getByText(/loading/i)).toBeInTheDocument() }) it('displays users after successful fetch', async () => { render(<UserList />) await waitFor(() => { expect(screen.getByText('John Doe')).toBeInTheDocument() expect(screen.getByText('Jane Smith')).toBeInTheDocument() }) }) it('displays error message on failed fetch', async () => { server.use( rest.get('/api/users', (req, res, ctx) => { return res(ctx.status(500)) }) ) render(<UserList />) await waitFor(() => { expect(screen.getByText(/failed to load users/i)).toBeInTheDocument() }) }) })
Page Testing
1. Static Page Testing
javascript// app/__tests__/page.test.js import { render, screen } from '@testing-library/react' import Home from '../page' describe('Home Page', () => { it('renders hero section', () => { render(<Home />) expect(screen.getByText(/welcome to our website/i)).toBeInTheDocument() }) it('renders navigation links', () => { render(<Home />) expect(screen.getByRole('link', { name: /about/i })).toBeInTheDocument() expect(screen.getByRole('link', { name: /contact/i })).toBeInTheDocument() }) })
2. Dynamic Page Testing
javascript// app/posts/[slug]/__tests__/page.test.js import { render, screen, waitFor } from '@testing-library/react' import { rest } from 'msw' import { setupServer } from 'msw/node' import PostPage from '../page' const server = setupServer( rest.get('/api/posts/:slug', (req, res, ctx) => { const { slug } = req.params return res( ctx.json({ id: 1, slug, title: 'Test Post', content: 'This is a test post content', author: 'John Doe' }) ) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) describe('Post Page', () => { it('renders post content', async () => { const params = { slug: 'test-post' } render(<PostPage params={params} />) await waitFor(() => { expect(screen.getByText('Test Post')).toBeInTheDocument() expect(screen.getByText('This is a test post content')).toBeInTheDocument() }) }) it('displays author information', async () => { const params = { slug: 'test-post' } render(<PostPage params={params} />) await waitFor(() => { expect(screen.getByText(/by john doe/i)).toBeInTheDocument() }) }) })
API Route Testing
1. GET Request Testing
javascript// app/api/users/__tests__/route.test.js import { GET } from '../route' import { NextRequest } from 'next/server' describe('/api/users GET endpoint', () => { it('returns list of users', async () => { const request = new NextRequest('http://localhost:3000/api/users') const response = await GET(request) expect(response.status).toBe(200) const data = await response.json() expect(Array.isArray(data)).toBe(true) expect(data.length).toBeGreaterThan(0) }) it('includes proper headers', async () => { const request = new NextRequest('http://localhost:3000/api/users') const response = await GET(request) expect(response.headers.get('content-type')).toBe('application/json') }) })
2. POST Request Testing
javascript// app/api/users/__tests__/route.test.js import { POST } from '../route' import { NextRequest } from 'next/server' describe('/api/users POST endpoint', () => { it('creates a new user', async () => { const userData = { name: 'John Doe', email: 'john@example.com' } const request = new NextRequest('http://localhost:3000/api/users', { method: 'POST', body: JSON.stringify(userData), headers: { 'Content-Type': 'application/json' } }) const response = await POST(request) expect(response.status).toBe(201) const data = await response.json() expect(data.id).toBeDefined() expect(data.name).toBe(userData.name) expect(data.email).toBe(userData.email) }) it('validates required fields', async () => { const request = new NextRequest('http://localhost:3000/api/users', { method: 'POST', body: JSON.stringify({ name: 'John' }), headers: { 'Content-Type': 'application/json' } }) const response = await POST(request) expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBeDefined() }) })
E2E Testing (Playwright)
1. Basic E2E Testing
javascript// e2e/home.spec.ts import { test, expect } from '@playwright/test' test.describe('Home Page', () => { test('loads homepage successfully', async ({ page }) => { await page.goto('/') await expect(page).toHaveTitle(/My Next.js App/) await expect(page.getByText('Welcome')).toBeVisible() }) test('navigates to about page', async ({ page }) => { await page.goto('/') await page.click('text=About') await expect(page).toHaveURL('/about') await expect(page.getByText('About Us')).toBeVisible() }) test('search functionality works', async ({ page }) => { await page.goto('/') await page.fill('input[name="search"]', 'Next.js') await page.click('button[type="submit"]') await expect(page).toHaveURL('/search?q=Next.js') await expect(page.getByText('Search Results')).toBeVisible() }) })
2. Form Submission Testing
javascript// e2e/login.spec.ts import { test, expect } from '@playwright/test' test.describe('Login Flow', () => { test('successful login', 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')).toBeVisible() }) test('shows error for invalid credentials', async ({ page }) => { await page.goto('/login') await page.fill('input[name="email"]', 'invalid@example.com') await page.fill('input[name="password"]', 'wrongpassword') await page.click('button[type="submit"]') await expect(page.getByText('Invalid credentials')).toBeVisible() await expect(page).toHaveURL('/login') }) })
3. Responsive Testing
javascript// e2e/responsive.spec.ts import { test, expect } from '@playwright/test' test.describe('Responsive Design', () => { test('mobile navigation works', async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }) await page.goto('/') const menuButton = page.getByRole('button', { name: /menu/i }) await expect(menuButton).toBeVisible() await menuButton.click() await expect(page.getByText('Home')).toBeVisible() await expect(page.getByText('About')).toBeVisible() }) test('desktop navigation is always visible', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }) await page.goto('/') await expect(page.getByText('Home')).toBeVisible() await expect(page.getByText('About')).toBeVisible() }) })
Testing Best Practices
1. Test Organization Structure
shell├── __tests__/ │ ├── setup.js │ └── utils.js ├── components/ │ └── __tests__/ │ ├── Button.test.js │ └── LoginForm.test.js ├── app/ │ ├── __tests__/ │ │ └── page.test.js │ └── api/ │ └── __tests__/ │ └── route.test.js ├── lib/ │ └── __tests__/ │ └── utils.test.js └── e2e/ ├── home.spec.ts ├── login.spec.ts └── responsive.spec.ts
2. Test Coverage
javascript// vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'e2e/', '**/*.config.*', '**/*.test.*', '**/*.spec.*' ], thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 } } } })
3. Test Utility Functions
javascript// __tests__/utils.js import { render } from '@testing-library/react' import { RouterContext } from 'next/dist/shared/lib/router-context' export function renderWithRouter(ui, { route = '/' } = {}) { return render( <RouterContext.Provider value={{ router: { asPath: route } }}> {ui} </RouterContext.Provider> ) } export function waitForLoadingToFinish(screen) { return screen.findByText(/loading/i).then(() => { return screen.findByText(/loading/i).then(el => { return !el }) }) }
A comprehensive testing strategy includes unit tests, integration tests, and end-to-end tests to ensure all layers of a Next.js application are thoroughly validated.