单元测试与组件测试

测试是代码的安全网。Next.js App Router 引入了 Server Components、Server Actions、async 组件等新范式,传统测试方式需要调整。本章用 Vitest + React Testing Library 搭建完整的测试体系。

1. 测试工具选型与配置

1.1 为什么选 Vitest 而非 Jest

维度VitestJest
速度基于 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 提供多种查询方法,优先级从高到低:

优先级方法场景
1getByRole按钮、链接、表单控件(最推荐,贴近可访问性)
2getByLabelText表单输入框
3getByPlaceholderText没有 label 的输入框
4getByText普通文本内容
5getByTestId兜底方案(给元素加 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 测试必须覆盖的场景

  1. 未认证auth() 返回 null → 抛错
  2. 输入验证:无效数据 → 返回错误信息
  3. 权限检查:角色不足 → 拒绝操作
  4. 正常路径:数据写入 + 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 说明需要重构