Next.js: 单元测试与集成测试

最后更新:2026-08-26

测试不是可选项——它是生产级应用的"安全气囊":平时用不上,关键时刻救你一命。

1. 你将学到


2. 一个全栈工程师的真实故事

(1) 痛点:上线前夜,一个小小的空格让支付页面崩溃

Alice 在一家服务于中东市场的电商平台担任全栈工程师。平台每天处理 50,000+ 订单,团队保持着一周两次的发布节奏。

上周五的上线中,一个看似无害的改动——在 OrderSummary 组件的价格格式函数里多加了一个空格——导致沙特用户的支付金额多显示了一位小数。虽然数据没有真正写错,但客服收到了 200+ 投诉,3 位用户因此放弃下单。

更糟糕的是:

Alice 下定决心:必须建立测试体系,阻止这类问题再次发生。

(2) Vitest + Testing Library 的解法

Alice 在项目中引入了 Vitest 测试栈:

BASH
npm install -D vitest @testing-library/react @testing-library/jest-dom @vitejs/plugin-react msw

然后创建了第一个测试:

TSX
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)天然兼容。

100%
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

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 文件

TS
// 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 可运行

TSX
// 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()
})
BASH
npx vitest run
💻 输出:

TEXT 📖 仅展示
 ✓ src/__tests__/basic.test.ts (1 test) 12ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

▶ 示例:package.json 添加测试脚本

JSON
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

4. @testing-library/react 组件测试

Testing Library 的核心哲学:测试用户能看到和交互的内容,而不是实现细节。

(1) render 与 screen 查询

100%
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 输出:

TSX
// 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
TSX
// 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(含用户交互)

TSX
// 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>
  )
}
TSX
// 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')

▶ 示例:表单验证测试

TSX
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 需要模拟数据库操作和缓存失效函数。

100%
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 工具

TS
// 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 示例

TS
// 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 测试

TS
// 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)拦截网络请求,无需启动真实后端即可测试数据获取逻辑。

100%
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

TS
// 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

TS
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
TS
// 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 测试

TSX
// 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 应用全栈测试

TSX
// 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)
  })
})
💻 输出:

TEXT 📖 仅展示
 ✓ src/__tests__/todo-comprehensive.test.ts (7 tests) 45ms

 Test Files  1 passed (1)
      Tests  7 passed (7)

❓ 常见问题

Q Vitest 和 Jest 有什么区别?
A Vitest 与 Vite 共享配置和插件生态,启动速度比 Jest 快 10-20 倍(E SM 原生支持),语法 API 与 Jest 兼容(globals API 可用 expect/describe/it)。Next.js 16 底层使用 Turbopack(Vite 生态),因此 Vitest 是更自然的选择。
Q 如何测试使用了 cookies()headers() 的 Server Component?
A 这些函数依赖 Next.js 的请求上下文,需要在测试中 mock。推荐将数据获取逻辑提取到独立的 Server Action 或 API 层,然后分别测试组件渲染和业务逻辑。
Q MSW 和手动 mock fetch 有什么区别?
A MSW 在 Service Worker 层拦截网络请求,组件代码无需任何改动就能测试真实 fetch 调用链路。手动 mock global.fetch 虽然简单,但无法覆盖复杂的请求匹配、响应序列化和错误场景。
Q 测试覆盖率阈值设多少比较合理?
A 建议循序渐进:Phase 1 设 50%(核心组件 + Server Actions),Phase 2 提到 70%(覆盖分支和边界情况),生产项目目标 80%+。切记覆盖率不是目标,关键路径(支付/登录/数据写入)必须 100% 覆盖。
Q 如何测试使用了 useActionState 的表单?
A useActionState 依赖 React 19 的 useFormState,在测试中需要 mock react-domuseFormState。推荐用 fireEvent.submit 触发表单提交,然后断言提交后的 UI 状态(成功/错误消息)。
Q Testing Library 的 fireEventuserEvent 有什么不同?
A fireEvent 直接触发 DOM 事件(如 click),而 userEvent 模拟更真实的用户交互(如键盘输入、焦点管理)。推荐生产测试用 userEvent(更贴近用户体验),单元测试用 fireEvent(更轻量)。

📖 小节


📝 作业

  1. 基础题(⭐):创建一个 vitest.config.ts,包含 React 插件、jsdom 环境、@/ 路径别名,并写一个最简单的 render(

    Hello
    ) 测试验证 Jest-DOM 匹配器正常工作。

  2. 进阶题(⭐⭐):为你的项目中的 Server Action(如 createUsersubmitOrder)编写完整测试:mock Prisma 的 create 方法、验证 revalidatePath 被调用、测试 Zod 校验失败时的错误返回。

  3. 挑战题(⭐⭐⭐):使用 MSW 构建三个 mock handler(GET /api/tasks、POST /api/tasks、DELETE /api/tasks/:id),然后为包含数据获取、创建和删除功能的 TaskList 组件编写 8 个以上测试用例(含加载态、空列表、错误处理)。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏