Testing Next.js Applications
Comprehensive guide to testing Next.js applications using modern testing frameworks and best practices.
Testing Pyramid for Next.js
The testing pyramid helps prioritize test coverage:
E2E (Playwright)
/ \
Integration Tests
/ \
Unit Tests (Vitest + RTL)
- •Unit Tests (70%): Test individual functions, components, Server Actions in isolation
- •Integration Tests (20%): Test multiple components/features working together, Route Handlers with middleware
- •E2E Tests (10%): Test complete user flows through the browser
Vitest Setup for Next.js
Installation
npm install -D vitest @vitest/ui jsdom npm install -D @testing-library/react @testing-library/user-event
vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
vitest.setup.ts
import { expect, afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock Next.js router if needed
vi.mock('next/router', () => ({
useRouter: () => ({
push: vi.fn(),
pathname: '/',
query: {},
}),
}))
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}))
React Testing Library Patterns
Core Concepts
- •Render: Mount a component in a test DOM
- •Screen: Query elements the way users see them (by role, label, text)
- •userEvent: Simulate user interactions (typing, clicking, etc.)
- •Within: Scope queries to a specific element
Common Queries (Preferred Order)
// 1. By accessible roles (most user-centric)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
// 2. By label text (forms)
screen.getByLabelText(/password/i)
// 3. By placeholder text
screen.getByPlaceholderText(/search/i)
// 4. By text content
screen.getByText(/welcome/i)
// 5. By test ID (last resort)
screen.getByTestId('custom-element')
Async Queries
// For elements that appear after loading
const button = await screen.findByRole('button', { name: /load/i })
// For elements that might not exist
const element = screen.queryByText(/might not exist/i)
expect(element).not.toBeInTheDocument()
Testing Server Components
Server Components are async and need special handling. They cannot be rendered directly in tests using render().
Approach 1: Test the Integration Point
Test the page or layout that uses the Server Component:
import { render, screen } from '@testing-library/react'
import { UserProfile } from '@/app/components/UserProfile'
// This only works if UserProfile is a Client Component
// For actual Server Components, see Approach 2
describe('UserProfile', () => {
it('displays user data', async () => {
const user = { id: '1', name: 'John' }
render(<UserProfile userId={user.id} />)
const name = await screen.findByText('John')
expect(name).toBeInTheDocument()
})
})
Approach 2: Test the Data Fetching
Extract data fetching logic into a separate function and test it independently:
// lib/user.ts
export async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) throw new Error('Failed to fetch user')
return response.json()
}
// lib/user.test.ts
import { vi } from 'vitest'
import { getUser } from './user'
describe('getUser', () => {
it('fetches user data', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: '1', name: 'John' }),
})
) as any
const user = await getUser('1')
expect(user.name).toBe('John')
})
it('throws on fetch error', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({ ok: false })
) as any
await expect(getUser('1')).rejects.toThrow('Failed to fetch user')
})
})
Approach 3: Mock the Data
If the Server Component fetches data, mock the data source:
import { vi } from 'vitest'
// Mock fetch for all tests
vi.stubGlobal('fetch', vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: '1', name: 'John' }),
})
))
describe('UserProfile Page', () => {
it('renders user profile', async () => {
// Component logic that uses the mocked fetch
})
})
Testing Server Actions
Server Actions are functions that run on the server. Test them like any async function.
// app/actions.ts
'use server'
export async function updateUser(id: string, data: Record<string, any>) {
// Validation
if (!id) throw new Error('User ID required')
// Update logic
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
if (!response.ok) throw new Error('Update failed')
return response.json()
}
// app/actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { updateUser } from './actions'
describe('Server Actions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('updates user successfully', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: '1', name: 'Updated Name' }),
})
) as any
const result = await updateUser('1', { name: 'Updated Name' })
expect(result.name).toBe('Updated Name')
expect(fetch).toHaveBeenCalledWith(
'/api/users/1',
expect.objectContaining({ method: 'PUT' })
)
})
it('throws error for missing ID', async () => {
await expect(updateUser('', { name: 'Test' })).rejects.toThrow('User ID required')
})
it('handles fetch errors', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({ ok: false })
) as any
await expect(updateUser('1', { name: 'Test' })).rejects.toThrow('Update failed')
})
it('validates input data', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
) as any
await updateUser('1', { email: 'test@example.com' })
const callArgs = (fetch as any).mock.calls[0]
const body = JSON.parse(callArgs[1].body)
expect(body.email).toBe('test@example.com')
})
})
Testing Route Handlers
Route Handlers are like API routes. Test them using NextRequest and mocking responses.
// app/api/users/route.ts
export async function GET(request: NextRequest) {
const id = request.nextUrl.searchParams.get('id')
if (!id) {
return Response.json(
{ error: 'ID required' },
{ status: 400 }
)
}
// Fetch data
return Response.json({ id, name: 'John' })
}
// app/api/users/route.test.ts
import { describe, it, expect, vi } from 'vitest'
import { GET } from './route'
import { NextRequest } from 'next/server'
describe('GET /api/users', () => {
it('returns user for valid ID', async () => {
const request = new NextRequest('http://localhost:3000/api/users?id=1')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.name).toBe('John')
})
it('returns 400 for missing ID', async () => {
const request = new NextRequest('http://localhost:3000/api/users')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('ID required')
})
})
Testing Middleware
Middleware can be tested by mocking the request/response and calling the middleware function.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/admin')) {
const token = request.cookies.get('auth')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return NextResponse.next()
}
// middleware.test.ts
import { describe, it, expect } from 'vitest'
import { middleware } from './middleware'
import { NextRequest } from 'next/server'
describe('Middleware', () => {
it('allows access with valid token', () => {
const request = new NextRequest(
new URL('http://localhost:3000/admin'),
{
cookies: new Map([['auth', 'valid-token']]),
}
)
const response = middleware(request)
expect(response?.status).not.toBe(307) // Not a redirect
})
it('redirects to login without token', () => {
const request = new NextRequest(
new URL('http://localhost:3000/admin')
)
const response = middleware(request)
expect(response?.status).toBe(307)
expect(response?.headers.get('location')).toContain('/login')
})
it('allows public pages without token', () => {
const request = new NextRequest(
new URL('http://localhost:3000/public')
)
const response = middleware(request)
expect(response?.status).not.toBe(307)
})
})
MSW (Mock Service Worker) for API Mocking
MSW allows you to mock HTTP requests at the network level without mocking fetch directly.
Setup
npm install -D msw npx msw init public --save
MSW Handlers
// lib/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/users', async ({ request }) => {
const body = await request.json()
if (!body.name) {
return HttpResponse.json(
{ error: 'Name required' },
{ status: 400 }
)
}
return HttpResponse.json(
{ id: '1', ...body },
{ status: 201 }
)
}),
http.put('/api/users/:id', async ({ params, request }) => {
const body = await request.json()
return HttpResponse.json({
id: params.id,
...body,
})
}),
http.delete('/api/users/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 })
}),
]
Test Setup with MSW
// lib/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// vitest.setup.ts (add to existing setup)
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './lib/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Example test using MSW
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserForm } from '@/components/UserForm'
describe('UserForm with MSW', () => {
it('submits form and displays success', async () => {
const user = userEvent.setup()
render(<UserForm />)
await user.type(screen.getByLabelText(/name/i), 'Jane Doe')
await user.click(screen.getByRole('button', { name: /submit/i }))
const success = await screen.findByText(/success/i)
expect(success).toBeInTheDocument()
})
it('handles server errors', async () => {
// Override handler for this test
server.use(
http.post('/api/users', () => {
return HttpResponse.json(
{ error: 'Server error' },
{ status: 500 }
)
})
)
const user = userEvent.setup()
render(<UserForm />)
await user.type(screen.getByLabelText(/name/i), 'Jane Doe')
await user.click(screen.getByRole('button', { name: /submit/i }))
const error = await screen.findByText(/error/i)
expect(error).toBeInTheDocument()
})
})
Common Testing Patterns
Testing Components with Hooks
describe('useUserData hook', () => {
it('fetches and sets user data', async () => {
const { result } = renderHook(() => useUserData('1'))
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.user.name).toBe('John')
})
})
Testing Context Providers
describe('UserContext', () => {
it('provides user data to children', () => {
render(
<UserProvider>
<UserConsumer />
</UserProvider>
)
expect(screen.getByText(/john/i)).toBeInTheDocument()
})
})
Testing Error Boundaries
describe('ErrorBoundary', () => {
it('catches errors and displays fallback', () => {
const ThrowError = () => {
throw new Error('Test error')
}
render(
<ErrorBoundary>
<ThrowError />
</ErrorBoundary>
)
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
})
})
What to Test vs What NOT to Test
DO Test
- •User interactions and workflows
- •Business logic and validation
- •Error handling and edge cases
- •Integration between components
- •Server Actions and Route Handlers
- •Conditional rendering based on props/state
- •Form submissions and data transformations
DON'T Test
- •Implementation details (how something is done)
- •Third-party library internals
- •CSS styles or exact HTML structure
- •React internals (rendering, updates, etc.)
- •Console logs or debug output
- •Snapshot tests (brittle and unhelpful)
Anti-Patterns to Avoid
// Testing implementation details
expect(component.state.isOpen).toBe(true)
// Testing user-visible behavior
expect(screen.getByRole('button', { name: /open/i })).toBeInTheDocument()
// Snapshot testing
expect(component).toMatchSnapshot()
// Testing specific properties
expect(screen.getByRole('heading')).toHaveTextContent('Welcome')
// Testing third-party library
expect(mockLibrary).toHaveBeenCalled()
// Testing your code's behavior
expect(screen.getByText(/success/i)).toBeInTheDocument()
Running Tests
# Run all tests npm run test # Run tests in watch mode npm run test -- --watch # Run tests with coverage npm run test -- --coverage # Run specific test file npm run test -- components/Button.test.ts # Run tests matching a pattern npm run test -- --grep "Button" # Open UI dashboard npm run test -- --ui
References
- •Vitest Documentation: https://vitest.dev/llms.txt
- •React Testing Library: See references/doc-lookup.md
- •Playwright E2E Testing: See references/test-templates.md
- •MSW Mocking: See references/test-templates.md