Next.js: 单元测试与集成测试
最后更新:2026-08-26
测试不是可选项——它是生产级应用的"安全气囊":平时用不上,关键时刻救你一命。
1. 你将学到
- 使用 Vitest 配置 Next.js 16 测试环境(vitest.config.ts + React 17M 兼容层)
- 用 @testing-library/react 渲染和断言 Server / Client 组件
- 为 Server Actions 编写集成测试(Mock Prisma + 模拟 revalidatePath)
- 使用 jest-dom 自定义匹配器(toBeInTheDocument / toHaveTextContent)
- 利用 MSW 拦截外部 API 请求,隔离网络依赖
2. 一个全栈工程师的真实故事
(1) 痛点:上线前夜,一个小小的空格让支付页面崩溃
Alice 在一家服务于中东市场的电商平台担任全栈工程师。平台每天处理 50,000+ 订单,团队保持着一周两次的发布节奏。
上周五的上线中,一个看似无害的改动——在 OrderSummary 组件的价格格式函数里多加了一个空格——导致沙特用户的支付金额多显示了一位小数。虽然数据没有真正写错,但客服收到了 200+ 投诉,3 位用户因此放弃下单。
更糟糕的是:
- 团队没有任何测试覆盖这个组件
- 手动测试只在 Chrome 上点了两下就放行了
- 这个 bug 在代码审查中也没被发现
Alice 下定决心:必须建立测试体系,阻止这类问题再次发生。
(2) Vitest + Testing Library 的解法
Alice 在项目中引入了 Vitest 测试栈:
npm install -D vitest @testing-library/react @testing-library/jest-dom @vitejs/plugin-react msw
然后创建了第一个测试:
import { render, screen } from '@testing-library/react'
import { OrderSummary } from './OrderSummary'
it('displays formatted price correctly', () => {
render(<OrderSummary total={99.99} currency="SAR" />)
expect(screen.getByText(/99\.99/)).toBeInTheDocument()
})
(3) 收益
| 维度 | 之前 | 之后 |
|---|---|---|
| 代码覆盖率 | < 5% | > 75% |
| 上线前回归测试 | 无 | 5 分钟自动跑完 |
| 生产 bug 率 | 每月 8-12 个 | 每月 0-2 个 |
| 新功能交付信心 | 低 | 高 |
3. Vitest 测试环境搭建
Vitest 是 Vite 生态的原生测试框架,与 Next.js 16(底层使用 Turbopack / Vite)天然兼容。
graph TB
A[vitest.config.ts] --> B[React 插件<br/>@vitejs/plugin-react]
A --> C[Test Globals<br/>globals:true]
A --> D[Environment<br/>jsdom]
A --> E[Setup Files<br/>setup-test.ts]
E --> F[jest-dom 匹配器]
E --> G[MSW 启动]
style A fill:#cce5ff
style F fill:#d4edda
style G fill:#d4edda
| 配置文件 | 作用 | 关键选项 |
|---|---|---|
vitest.config.ts |
测试主配置 | environment: 'jsdom' 模拟浏览器 |
setup-test.ts |
全局初始化 | 导入 jest-dom、启动 MSW 服务 |
tsconfig.json |
类型支持 | types: ['vitest/globals'] |
(1) 配置 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: './src/__tests__/setup-test.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
thresholds: {
statements: 70,
branches: 60,
functions: 70,
lines: 70
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
(2) 全局 Setup 文件
// src/__tests__/setup-test.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
})
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams()
}))
vi.mock('react-dom', async () => {
const actual = await vi.importActual('react-dom')
return { ...actual, useFormState: vi.fn() }
})
▶ 示例:验证 Vitest 可运行
// src/__tests__/basic.test.ts
import { render, screen } from '@testing-library/react'
function Hello({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>
}
it('renders hello message', () => {
render(<Hello name="Alice" />)
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument()
})
npx vitest run
✓ src/__tests__/basic.test.ts (1 test) 12ms
Test Files 1 passed (1)
Tests 1 passed (1)
▶ 示例:package.json 添加测试脚本
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
4. @testing-library/react 组件测试
Testing Library 的核心哲学:测试用户能看到和交互的内容,而不是实现细节。
(1) render 与 screen 查询
graph LR
A[render 组件] --> B[screen 查询]
B --> C{查询类型}
C --> D[getByText 文本]
C --> E[getByRole 语义]
C --> F[getByTestId 测试 ID]
C --> G[getByPlaceholderText 占位符]
D --> H[断言 expect]
E --> H
F --> H
G --> H
| 查询方法 | 适用场景 | 示例 |
|---|---|---|
getByText |
文本内容 | getByText('提交订单') |
getByRole |
语义元素 | getByRole('button', { name: /提交/i }) |
getByPlaceholderText |
输入框提示 | getByPlaceholderText('输入邮箱') |
getByTestId |
无语义元素 | getByTestId('order-total') |
queryByText |
不存在断言 | expect(queryByText('错误')).not.toBeInTheDocument() |
(2) 测试 Server Components(同步渲染)
Server Components 无需客户端 JS,测试时直接断言 HTML 输出:
// src/app/products/page.tsx
async function ProductsPage() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return (
<ul>
{products.map((p: { id: number; name: string; price: number }) => (
<li key={p.id} data-testid="product-item">
{p.name} — ${p.price}
</li>
))}
</ul>
)
}
export default ProductsPage
// src/__tests__/products-page.test.tsx
import { render, screen } from '@testing-library/react'
import ProductsPage from '@/app/products/page'
// Mock global fetch
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve([
{ id: 1, name: 'iPhone 16', price: 999 },
{ id: 2, name: 'Samsung S26', price: 899 }
])
})
it('renders product list from server', async () => {
const page = await ProductsPage()
render(page)
expect(screen.getByText('iPhone 16')).toBeInTheDocument()
expect(screen.getByText('Samsung S26')).toBeInTheDocument()
expect(screen.getAllByTestId('product-item')).toHaveLength(2)
})
▶ 示例:测试 Client Components(含用户交互)
// src/components/Counter.tsx
'use client'
import { useState } from 'react'
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial)
return (
<div>
<p data-testid="count">Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
)
}
// src/__tests__/counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
describe('Counter', () => {
it('renders with initial value', () => {
render(<Counter initial={5} />)
expect(screen.getByTestId('count')).toHaveTextContent('Count: 5')
})
it('increments on button click', () => {
render(<Counter initial={0} />)
fireEvent.click(screen.getByText('Increment'))
expect(screen.getByTestId('count')).toHaveTextContent('Count: 1')
})
it('decrements on button click', () => {
render(<Counter initial={10} />)
fireEvent.click(screen.getByText('Decrement'))
expect(screen.getByTestId('count')).toHaveTextContent('Count: 9')
})
})
5. jest-dom 自定义匹配器
jest-dom 提供语义化的 DOM 断言匹配器,让测试代码更接近自然语言。
| 匹配器 | 作用 | 示例 |
|---|---|---|
toBeInTheDocument() |
元素在 DOM 中 | expect(el).toBeInTheDocument() |
toHaveTextContent(text) |
文本内容匹配 | expect(el).toHaveTextContent('Hello') |
toBeVisible() |
元素可见 | expect(el).toBeVisible() |
toBeDisabled() |
按钮禁用 | expect(btn).toBeDisabled() |
toHaveClass(cls) |
CSS class 匹配 | expect(el).toHaveClass('active') |
toHaveAttribute(attr) |
属性匹配 | expect(input).toHaveAttribute('type', 'email') |
toHaveValue(val) |
表单值匹配 | expect(input).toHaveValue('test@example.com') |
▶ 示例:表单验证测试
import { render, screen, fireEvent } from '@testing-library/react'
import { LoginForm } from '@/components/LoginForm'
describe('LoginForm validation', () => {
it('shows error on empty email', () => {
render(<LoginForm />)
fireEvent.click(screen.getByRole('button', { name: /登录/i }))
expect(screen.getByText(/请输入邮箱/i)).toBeInTheDocument()
expect(screen.getByText(/请输入邮箱/i)).toBeVisible()
})
it('disables submit while loading', () => {
render(<LoginForm />)
fireEvent.change(screen.getByPlaceholderText('输入邮箱'), {
target: { value: 'alice@example.com' }
})
fireEvent.click(screen.getByRole('button', { name: /登录/i }))
expect(screen.getByRole('button', { name: /登录中/i })).toBeDisabled()
})
it('clears error after valid input', () => {
render(<LoginForm />)
fireEvent.click(screen.getByRole('button', { name: /登录/i }))
expect(screen.getByText(/请输入邮箱/i)).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('输入邮箱'), {
target: { value: 'alice@example.com' }
})
expect(screen.queryByText(/请输入邮箱/i)).not.toBeInTheDocument()
})
})
6. 测试 Server Actions(Mock Prisma + revalidatePath)
Server Actions 需要模拟数据库操作和缓存失效函数。
graph TB
A[测试 Server Action] --> B[Mock Prisma Client]
A --> C[Mock revalidatePath]
A --> D[Mock redirect]
B --> E[返回模拟数据]
C --> F[验证被调用次数/参数]
D --> G[验证重定向路径]
style A fill:#cce5ff
style B fill:#d4edda
style C fill:#fff3cd
(1) Mock Prisma 工具
// src/__tests__/utils/mock-prisma.ts
import { vi } from 'vitest'
export function createMockPrisma() {
return {
task: {
findMany: vi.fn().mockResolvedValue([
{ id: '1', title: 'Test Task', status: 'TODO', projectId: 'p1' }
]),
create: vi.fn().mockImplementation(({ data }) => Promise.resolve({
id: 'new-id',
...data,
createdAt: new Date()
})),
update: vi.fn().mockImplementation(({ data }) => Promise.resolve(data)),
delete: vi.fn().mockResolvedValue({ id: 'deleted-id' })
},
project: {
findUnique: vi.fn().mockResolvedValue({
id: 'p1',
name: 'Test Project',
tasks: []
})
},
$transaction: vi.fn().mockImplementation((cb) => cb(createMockPrisma()))
} as any
}
(2) 测试 Server Action 示例
// src/actions/task.ts
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const taskSchema = z.object({
title: z.string().min(1, '标题不能为空'),
projectId: z.string().min(1),
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE'])
})
export async function createTask(formData: FormData) {
const data = Object.fromEntries(formData)
const parsed = taskSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await prisma.task.create({ data: parsed.data })
revalidatePath(`/projects/${parsed.data.projectId}`)
redirect(`/projects/${parsed.data.projectId}`)
}
▶ 示例:完整 Server Action 测试
// src/__tests__/actions/task.test.ts
import { createTask } from '@/actions/task'
import { createMockPrisma } from '../utils/mock-prisma'
import { vi, describe, it, expect, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({ prisma: createMockPrisma() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
describe('createTask', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a task successfully', async () => {
const formData = new FormData()
formData.append('title', '撰写测试文档')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
const result = await createTask(formData)
expect(result).toBeUndefined()
})
it('returns validation error for empty title', async () => {
const formData = new FormData()
formData.append('title', '')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
const result = await createTask(formData)
expect(result).toHaveProperty('error')
expect(result.error.title).toBeDefined()
})
it('calls revalidatePath after creation', async () => {
const formData = new FormData()
formData.append('title', 'New Task')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
await createTask(formData)
const { revalidatePath } = await import('next/cache')
expect(revalidatePath).toHaveBeenCalledWith('/projects/p1')
})
})
7. MSW Mock 服务端 API
MSW(Mock Service Worker)拦截网络请求,无需启动真实后端即可测试数据获取逻辑。
graph LR
A[组件发起 fetch] --> B[MSW Service Worker]
B --> C{请求匹配}
C -->|匹配处理程序| D[返回 mock 数据]
C -->|不匹配| E[透传到真实网络]
style B fill:#cce5ff
style D fill:#d4edda
| 概念 | 说明 | 示例 |
|---|---|---|
| Handler | 请求处理函数 | http.get('/api/products', resolver) |
| Resolver | 返回 mock 响应 | return HttpResponse.json([...]) |
| Server | Node.js mock 服务器 | setupServer(...handlers) |
| Browser | 浏览器端 MSW | setupWorker(...handlers) |
(1) 定义 Mock Handlers
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
const API_BASE = 'https://api.example.com'
export const handlers = [
// GET /api/products
http.get(`${API_BASE}/products`, () => {
return HttpResponse.json([
{ id: 1, name: 'iPhone 16', price: 999, category: 'Electronics' },
{ id: 2, name: 'Samsung S26', price: 899, category: 'Electronics' }
])
}),
// POST /api/orders
http.post(`${API_BASE}/orders`, async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
id: 'order-123',
...body as any,
status: 'confirmed',
createdAt: new Date().toISOString()
})
}),
// GET /api/users/:id
http.get(`${API_BASE}/users/:id`, ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Wang',
email: 'alice@example.com',
role: 'admin'
})
}),
// 500 error for testing error boundaries
http.get(`${API_BASE}/errors/internal`, () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
})
]
(2) 集成到测试 Setup
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/__tests__/setup-test.ts(完整版)
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, afterAll, beforeAll, vi } from 'vitest'
import { server } from '@/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { cleanup(); server.resetHandlers() })
afterAll(() => server.close())
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(), replace: vi.fn(),
refresh: vi.fn(), back: vi.fn(), forward: vi.fn()
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams()
}))
▶ 示例:MSW 拦截 API 测试
// src/__tests__/api-integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/server'
import ProductsPage from '@/app/products/page'
describe('ProductsPage with MSW', () => {
it('renders products from mocked API', async () => {
const page = await ProductsPage()
render(page)
expect(screen.getByText('iPhone 16')).toBeInTheDocument()
expect(screen.getByText('Samsung S26')).toBeInTheDocument()
})
it('handles empty product list', async () => {
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json([])
})
)
const page = await ProductsPage()
render(page)
await waitFor(() => {
expect(screen.queryByTestId('product-item')).not.toBeInTheDocument()
})
})
it('shows error on API failure', async () => {
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json(
{ error: 'Service unavailable' },
{ status: 503 }
)
})
)
await expect(ProductsPage()).rejects.toThrow()
})
})
8. 完整示例:Todo 应用全栈测试
// src/__tests__/todo-comprehensive.test.ts
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/server'
import { createMockPrisma } from './utils/mock-prisma'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================
// 综合测试:Todo 应用的组件 + Action + API
// ============================================
// --- 1. Mock 依赖 ---
vi.mock('@/lib/prisma', () => ({ prisma: createMockPrisma() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() })
}))
// --- 2. Todo 组件 ---
function TodoItem({ id, title, done, onToggle }: {
id: string; title: string; done: boolean
onToggle: (id: string) => void
}) {
return (
<div data-testid="todo-item">
<span style={{ textDecoration: done ? 'line-through' : 'none' }}>
{title}
</span>
<button onClick={() => onToggle(id)}>
{done ? 'Undo' : 'Done'}
</button>
</div>
)
}
function TodoList({ todos }: { todos: Array<{ id: string; title: string; done: boolean }> }) {
return (
<div>
{todos.map(t => (
<TodoItem key={t.id} {...t} onToggle={(id) => {
const idx = todos.findIndex(t => t.id === id)
todos[idx].done = !todos[idx].done
}} />
))}
</div>
)
}
// --- 3. 测试用例 ---
describe('Todo App', () => {
const mockTodos = [
{ id: '1', title: 'Learn Next.js testing', done: false },
{ id: '2', title: 'Write unit tests', done: true },
{ id: '3', title: 'Set up CI pipeline', done: false }
]
it('renders all todos', () => {
render(<TodoList todos={mockTodos} />)
expect(screen.getAllByTestId('todo-item')).toHaveLength(3)
expect(screen.getByText('Learn Next.js testing')).toBeInTheDocument()
})
it('shows strikethrough for done items', () => {
render(<TodoList todos={mockTodos} />)
const doneItem = screen.getByText('Write unit tests')
expect(doneItem).toHaveStyle('text-decoration: line-through')
})
it('toggles todo on button click', () => {
render(<TodoList todos={mockTodos} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(buttons[0]).toHaveTextContent('Undo')
})
it('has correct button labels', () => {
render(<TodoList todos={mockTodos} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toHaveTextContent('Done')
expect(buttons[1]).toHaveTextContent('Undo')
})
})
// --- 4. MSW API 测试 ---
describe('Todo API Mock', () => {
it('fetch todos from mocked API', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.json(mockTodos)
})
)
const res = await fetch('https://api.example.com/todos')
const data = await res.json()
expect(data).toHaveLength(3)
expect(data[0].title).toBe('Learn Next.js testing')
})
it('handles API error gracefully', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.json(null, { status: 500 })
})
)
const res = await fetch('https://api.example.com/todos')
expect(res.status).toBe(500)
})
})
✓ src/__tests__/todo-comprehensive.test.ts (7 tests) 45ms
Test Files 1 passed (1)
Tests 7 passed (7)
❓ 常见问题
cookies() 或 headers() 的 Server Component?global.fetch 虽然简单,但无法覆盖复杂的请求匹配、响应序列化和错误场景。useActionState 的表单?useActionState 依赖 React 19 的 useFormState,在测试中需要 mock react-dom 的 useFormState。推荐用 fireEvent.submit 触发表单提交,然后断言提交后的 UI 状态(成功/错误消息)。fireEvent 和 userEvent 有什么不同?fireEvent 直接触发 DOM 事件(如 click),而 userEvent 模拟更真实的用户交互(如键盘输入、焦点管理)。推荐生产测试用 userEvent(更贴近用户体验),单元测试用 fireEvent(更轻量)。📖 小节
- Vitest 是 Next.js 16 的首选测试框架,配置
vitest.config.ts时需指定environment: 'jsdom'和 React 插件 @testing-library/react提供render和screenAPI,聚焦用户可见行为而非实现细节- jest-dom 匹配器(
toBeInTheDocument、toHaveTextContent、toBeVisible)让测试断言语义化 - Server Actions 测试需 mock Prisma Client、
revalidatePath和redirect,验证数据写入和缓存失效 - MSW 在 Service Worker 层拦截网络请求,适用于集成测试中的外部 API 隔离
setup-test.ts是测试基础设施的关键文件,集中管理 mock 和全局初始化
📝 作业
-
基础题(⭐):创建一个
vitest.config.ts,包含 React 插件、jsdom 环境、@/路径别名,并写一个最简单的render(Hello)测试验证 Jest-DOM 匹配器正常工作。 -
进阶题(⭐⭐):为你的项目中的 Server Action(如
createUser或submitOrder)编写完整测试:mock Prisma 的 create 方法、验证revalidatePath被调用、测试 Zod 校验失败时的错误返回。 -
挑战题(⭐⭐⭐):使用 MSW 构建三个 mock handler(GET /api/tasks、POST /api/tasks、DELETE /api/tasks/:id),然后为包含数据获取、创建和删除功能的 TaskList 组件编写 8 个以上测试用例(含加载态、空列表、错误处理)。