- name
- testing-patterns
- description
- Next.js testing patterns with Vitest and Playwright. Use when the user asks about "testing Next.js", "unit tests", "integration tests", "E2E tests", "Vitest with Next.js", "Playwright", "testing Server Components", "testing Server Actions", "testing Route Handlers", "mocking next/navigation", "mocking next/headers", or needs guidance on test setup and patterns for App Router applications.
# Testing Patterns
## Types of Tests
| Type | Tool | What it tests |
|------|------|---------------|
| Unit / Component | **Vitest** + React Testing Library | Individual components, hooks, utils |
| End-to-End | **Playwright** | Full user flows in a real browser |
| Snapshot | **Vitest** | Component rendered output stability |
> **Note:** `async` Server Components are not fully supported in unit test frameworks yet. Use E2E testing for `async` Server Components.
## Vitest Setup
### Installation
```bash
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom
```
Vitest 5 requires Node.js >= 22.12 and Vite >= 6.4 (Node 20 is not supported). `vite` is now a peer dependency: npm, pnpm and Bun install it automatically, but with Yarn add it explicitly (`yarn add -D vite`). `clearMocks` now defaults to `true`, so recorded mock calls are cleared before every test — set `clearMocks: false` in the Vitest config only if a suite relies on history carrying over.
### Configuration
```typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
css: true,
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
```
```typescript
// vitest.setup.ts
import '@testing-library/jest-dom/vitest'
```
### Add test script
```jsonc
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
```
## Testing Client Components
```tsx
// src/components/counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
)
}
```
```tsx
// src/components/__tests__/counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Counter } from '../counter'
describe('Counter', () => {
it('renders initial count of 0', () => {
render(<Counter />)
expect(screen.getByText('Count: 0')).toBeInTheDocument()
})
it('increments count on click', () => {
render(<Counter />)
fireEvent.click(screen.getByRole('button', { name: /increment/i }))
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
})
```
## Mocking `next/navigation`
```tsx
// src/components/__tests__/nav-component.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock next/navigation
const pushMock = vi.fn()
const replaceMock = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
replace: replaceMock,
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/current-path',
useSearchParams: () => new URLSearchParams('q=test'),
useParams: () => ({ id: '123' }),
}))
import { SearchForm } from '../search-form'
describe('SearchForm', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('navigates on submit', () => {
render(<SearchForm />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'nextjs' } })
fireEvent.submit(screen.getByRole('form'))
expect(pushMock).toHaveBeenCalledWith('/search?q=nextjs')
})
})
```
## Mocking `next/headers`
For testing code that uses `cookies()` or `headers()`:
```typescript
// src/lib/__tests__/session.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockCookies = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
}
vi.mock('next/headers', () => ({
cookies: vi.fn(async () => mockCookies),
headers: vi.fn(async () => new Headers({ 'x-custom': 'value' })),
}))
import { getSession, deleteSession } from '../session'
describe('Session', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null when no session cookie', async () => {
mockCookies.get.mockReturnValue(undefined)
const session = await getSession()
expect(session).toBeNull()
})
it('deletes session cookie', async () => {
await deleteSession()
expect(mockCookies.delete).toHaveBeenCalledWith('session')
})
})
```
## Testing Server Actions
Mock the dependencies, test the logic:
```typescript
// src/app/actions/__tests__/post-actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock dependencies
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}))
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
}))
vi.mock('@/lib/db', () => ({
db: {
post: {
create: vi.fn(),
delete: vi.fn(),
},
},
}))
import { createPost } from '../post-actions'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
describe('createPost', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a post with valid data', async () => {
const formData = new FormData()
formData.set('title', 'Test Post')
formData.set('content', 'Test content that is long enough')
vi.mocked(db.post.create).mockResolvedValue({ id: '1', title: 'Test Post' })
const result = await createPost({ message: '' }, formData)
expect(db.post.create).toHaveBeenCalled()
expect(revalidatePath).toHaveBeenCalledWith('/posts')
expect(result.success).toBe(true)
})
it('returns validation errors for invalid data', async () => {
const formData = new FormData()
formData.set('title', '')
formData.set('content', 'short')
const result = await createPost({ message: '' }, formData)
expect(result.errors).toBeDefined()
expect(db.post.create).not.toHaveBeenCalled()
})
})
```
## Testing Route Handlers
Create mock `NextRequest` objects:
```typescript
// src/app/api/__tests__/posts.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
vi.mock('@/lib/db', () => ({
db: {
post: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}))
import { GET, POST } from '../posts/route'
import { db } from '@/lib/db'
describe('GET /api/posts', () => {
it('returns posts as JSON', async () => {
const mockPosts = [{ id: '1', title: 'Test' }]
vi.mocked(db.post.findMany).mockResolvedValue(mockPosts)
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(data).toEqual(mockPosts)
})
it('handles query params', async () => {
vi.mocked(db.post.findMany).mockResolvedValue([])
const request = new NextRequest('http://localhost:3000/api/posts?page=2&limit=5')
await GET(request)
expect(db.post.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 5, take: 5 })
)
})
})
describe('POST /api/posts', () => {
it('creates a post and returns 201', async () => {
const newPost = { id: '1', title: 'New Post', content: 'Content' }
vi.mocked(db.post.create).mockResolvedValue(newPost)
const request = new NextRequest('http://localhost:3000/api/posts', {
method: 'POST',
body: JSON.stringify({ title: 'New Post', content: 'Content' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.title).toBe('New Post')
})
it('returns 422 for invalid body', async () => {
const request = new NextRequest('http://localhost:3000/api/posts', {
method: 'POST',
body: JSON.stringify({ title: '' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(422)
})
})
```
## Playwright E2E Setup
### Installation
```bash
npm install -D @playwright/test
npx playwright install
```
### Configuration
```typescript
// 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,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
View on GitHub