单元测试与组件测试
测试是代码的安全网。Next.js App Router 引入了 Server Components、Server Actions、
async组件等新范式,传统测试方式需要调整。本章用 Vitest + React Testing Library 搭建完整的测试体系。
1. 测试工具选型与配置
1.1 为什么选 Vitest 而非 Jest
| 维度 | Vitest | Jest |
|---|---|---|
| 速度 | 基于 Vite + ESM 原生,快 2-5 倍 | CommonJS 转换开销大 |
| TypeScript | 零配置支持 | 需要 ts-jest 或 SWC |
| API 兼容 | 与 Jest 几乎 100% 兼容 | — |
| 配置复用 | 共享 vite.config.ts | 独立配置 |
| 覆盖率 | --coverage 内置 v8 provider | 需额外安装 |
核心观点:Vitest 对现代 ESM + TypeScript 项目是更优选择。如果团队已有大量 Jest 测试,迁移成本很低——API 几乎一致,改改 import 就行。
1.2 安装与配置
pnpm add -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom// 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', // 模拟浏览器 DOM
globals: true, // 全局注入 describe/it/expect(不用每个文件 import)
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', '**/*.config.*'],
},
},
resolve: {
alias: { '@': path.resolve(__dirname, './') }, // 匹配 tsconfig paths
},
})// vitest.setup.ts — 扩展 expect 匹配器
import '@testing-library/jest-dom/vitest'易踩坑点:resolve.alias 必须和 tsconfig.json 中的 paths 一致,否则测试中 @/lib/xxx 会报找不到模块。
2. 测试思维:该测什么
2.1 React Testing Library 的核心哲学
"The more your tests resemble the way your software is used, the more confidence they can give you."
像用户一样测试——不测实现细节(state 值、内部方法),只测用户看到什么、能做什么:
- ❌ 测试 state 是否为 3 → ✅ 测试页面上是否显示 "3 items"
- ❌ 测试组件是否调用了 setState → ✅ 测试点击按钮后是否出现新内容
- ❌ 用
wrapper.instance()访问内部 → ✅ 用screen.getByRole查找可见元素
2.2 查询优先级
RTL 提供多种查询方法,优先级从高到低:
| 优先级 | 方法 | 场景 |
|---|---|---|
| 1 | getByRole | 按钮、链接、表单控件(最推荐,贴近可访问性) |
| 2 | getByLabelText | 表单输入框 |
| 3 | getByPlaceholderText | 没有 label 的输入框 |
| 4 | getByText | 普通文本内容 |
| 5 | getByTestId | 兜底方案(给元素加 data-testid) |
原则:优先用语义化查询。getByRole('button', { name: '提交' }) 比 getByTestId('submit-btn') 好——前者同时验证了可访问性。
2.3 测试金字塔
/\
/ \ E2E 测试(少量,慢,高信心) → Playwright
/----\
/ \ 集成测试(适量,中速) → Vitest + 真实依赖
/--------\ 单元测试(大量,快,低信心) → Vitest + Mock
3. Client Component 测试
Client Component 测试最直接——渲染组件,模拟用户操作,断言结果:
// components/__tests__/counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from '@/components/counter'
describe('Counter', () => {
it('renders initial count and responds to clicks', async () => {
const user = userEvent.setup()
render(<Counter initialCount={5} />)
expect(screen.getByText('Count: 5')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Increment' }))
expect(screen.getByText('Count: 6')).toBeInTheDocument()
})
})关键要点:
- 用
userEvent.setup()而非fireEvent——前者模拟真实用户行为链(focus → keydown → input → keyup),后者只触发单个事件 - 异步操作后用
waitFor等待 DOM 更新 - 用
vi.fn()创建 Mock 函数验证回调:expect(onSearch).toHaveBeenCalledWith('query')
异步数据加载测试
// 测试 loading → success / error 的状态流转
it('shows loading then data', async () => {
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'Alice' })),
)
render(<UserProfile userId="1" />)
expect(screen.getByRole('status')).toHaveTextContent('Loading...')
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
})4. Server Component 测试
4.1 核心挑战
Server Components 是 async 函数,依赖服务端能力(数据库、cookies()、headers())。jsdom 没有这些——必须 Mock。
4.2 两种策略
策略一:Mock 依赖后直接 await 渲染
vi.mock('@/auth', () => ({ auth: vi.fn() }))
vi.mock('@/lib/db', () => ({ db: { query: { users: { findFirst: vi.fn() } } } }))
import { auth } from '@/auth'
import { db } from '@/lib/db'
it('renders user info', async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: '1' } } as any)
vi.mocked(db.query.users.findFirst).mockResolvedValue({ name: 'Alice', plan: 'pro' })
// 关键:Server Component 是 async 函数,先 await 再 render
const jsx = await UserCard()
render(jsx)
expect(screen.getByText('Alice')).toBeInTheDocument()
})策略二:提取逻辑为纯函数,单独测试(推荐)
与其 Mock 一堆依赖来测 Server Component,不如把业务逻辑提取到纯函数中:
// lib/dashboard.ts — 提取出的纯函数
export function calculateStats(orders: Order[]) {
const total = orders.reduce((sum, o) => sum + o.amount, 0)
const average = orders.length > 0 ? total / orders.length : 0
return { total, average, count: orders.length }
}
// 测试纯函数——零 Mock,零依赖,零副作用
it('calculates totals', () => {
const stats = calculateStats([{ amount: 1000 }, { amount: 2000 }])
expect(stats.total).toBe(3000)
expect(stats.average).toBe(1500)
})经验法则:Server Component 的复杂 UI 交互留给 E2E 测试(Playwright),单元测试聚焦纯逻辑。
5. Server Action 测试
Server Actions 本质是函数——直接调用即可,但需要 Mock 掉 Next.js 运行时 API:
vi.mock('@/auth', () => ({ auth: vi.fn() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
it('throws when not authenticated', async () => {
vi.mocked(auth).mockResolvedValue(null)
const formData = new FormData()
formData.set('title', 'Hello')
await expect(createPost(formData)).rejects.toThrow('Unauthorized')
})Server Action 测试必须覆盖的场景:
- 未认证:
auth()返回 null → 抛错 - 输入验证:无效数据 → 返回错误信息
- 权限检查:角色不足 → 拒绝操作
- 正常路径:数据写入 +
revalidatePath被调用
6. Mock 策略
6.1 三种 Mock 模式
// 1. vi.mock — 替换整个模块(最常用)
vi.mock('@/lib/db', () => ({ db: { query: { users: { findFirst: vi.fn() } } } }))
// 2. vi.spyOn — 监视/临时替换某个方法(保留其他方法的真实实现)
vi.spyOn(global, 'fetch').mockResolvedValueOnce(new Response('ok'))
// 3. 手动 Mock 文件 — 放在 __mocks__/ 目录,自动应用
// __mocks__/next/navigation.ts
export const useRouter = vi.fn(() => ({ push: vi.fn(), replace: vi.fn() }))6.2 何时 Mock,何时不 Mock
| 场景 | Mock? | 原因 |
|---|---|---|
| 数据库 / ORM | ✅ | 单元测试不应依赖外部服务 |
auth() / cookies() | ✅ | Next.js 运行时 API,jsdom 中不存在 |
fetch 外部 API | ✅ | 不依赖第三方可用性 |
| 纯工具函数 | ❌ | 直接测真实逻辑,Mock 了就失去意义 |
revalidatePath | ✅ | 验证是否被正确调用 |
反模式警告:过度 Mock 会让测试变成"测试 Mock 而非测试代码"。如果一个测试需要 Mock 5 个以上的模块,说明被测代码耦合太重——先重构,再测试。
6.3 统一管理 Next.js Mock
// test/mocks/next.ts — 所有测试共用
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() })),
usePathname: vi.fn(() => '/'),
useSearchParams: vi.fn(() => new URLSearchParams()),
redirect: vi.fn(),
}))
vi.mock('next/headers', () => ({
headers: vi.fn(() => new Map()),
cookies: vi.fn(() => ({ get: vi.fn(), set: vi.fn() })),
}))
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}))7. 最佳实践
7.1 测试文件组织
推荐 __tests__/ 目录紧邻源文件——方便查找,也方便判断哪些模块缺少测试:
lib/
├── utils.ts
└── __tests__/utils.test.ts
components/
├── counter.tsx
└── __tests__/counter.test.tsx
7.2 各层覆盖策略
| 层 | 覆盖目标 | 手段 |
|---|---|---|
| 工具函数 | 100% | 纯函数直接测试,边界值全覆盖 |
| Client Component | 用户交互 + 状态流转 | RTL + userEvent |
| Server Component | 提取纯逻辑测试 | 复杂 UI 留给 E2E |
| Server Action | 认证 / 验证 / 权限 / 正常路径 | 直接调用 + Mock |
| Hooks | 输入输出 + 副作用 | renderHook |
7.3 常用命令
pnpm test # watch 模式(开发时用)
pnpm test -- --run # 单次运行(CI 用)
pnpm test -- --coverage # 覆盖率报告本章小结
- Vitest:比 Jest 更快、TypeScript 零配置,API 完全兼容,迁移成本极低
- RTL 哲学:像用户一样测试,查询优先用
getByRole,不测实现细节 - Server Component:要么 Mock 依赖后
await渲染,要么提取纯函数单独测试(推荐后者) - Server Action:直接调用函数,必测认证 / 验证 / 权限三条路径
- Mock 原则:外部依赖必 Mock,纯逻辑不 Mock,超过 5 个 Mock 说明需要重构