大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
测试
这一课你将能做到
- 用 Vitest + React Testing Library 配置 Next.js 单元测试环境
- 测试 Server Component、Client Component、页面组件的不同策略
- 用 Playwright 编写端到端测试,覆盖关键用户路径
- Mock fetch、数据库、第三方服务,隔离外部依赖
- 配置 CI 流水线自动运行测试
为什么要学
没有测试的代码是「祈祷式编程」:改一个样式怕把别的页面搞崩,加一个功能怕把旧功能搞挂。Next.js 项目尤其需要测试,因为 Server Component 和 Client Component 的边界、数据获取的缓存行为、路由的动态参数,都是传统 React 测试没覆盖的新场景。
人话:测试是代码的「保险丝」。平时感觉不到存在,一旦短路(改坏了),保险丝先断,不会烧了整个房子(线上事故)。
先建立图像
测试金字塔(从下到上):
├── 单元测试(最多) ← Vitest + RTL,测组件渲染、工具函数
├── 集成测试(适中) ← 测组件组合、数据流、路由跳转
└── E2E 测试(最少) ← Playwright,测真实浏览器完整流程
Next.js 测试的特殊性:
├── Server Component → 在 Node.js 环境测,直接调用 async 函数
├── Client Component → 在 jsdom 环境测,模拟浏览器行为
└── 页面组件 → 组合两者,用 MSW Mock API
跟着做
步骤 1:安装 Vitest + React Testing Library
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom
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',
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
});
vitest.setup.ts:
import '@testing-library/jest-dom/vitest';
package.json 加脚本:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
步骤 2:测试 Client Component
app/components/Counter.tsx:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>当前计数: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<button onClick={() => setCount(0)}>重置</button>
</div>
);
}
app/components/Counter.test.tsx:
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
describe('Counter', () => {
it('初始显示 0', () => {
render(<Counter />);
expect(screen.getByText('当前计数: 0')).toBeInTheDocument();
});
it('点击 +1 按钮增加计数', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+1'));
expect(screen.getByText('当前计数: 1')).toBeInTheDocument();
});
it('点击重置按钮归零', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+1'));
fireEvent.click(screen.getByText('+1'));
fireEvent.click(screen.getByText('重置'));
expect(screen.getByText('当前计数: 0')).toBeInTheDocument();
});
});
运行 npm run test,三个测试全部通过。
步骤 3:测试 Server Component
Server Component 是 async 函数,在 Node.js 环境直接调用:
app/lib/utils.ts:
export async function fetchUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error('获取用户失败');
return res.json();
}
app/lib/utils.test.ts:
import { fetchUser } from './utils';
global.fetch = vi.fn();
describe('fetchUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('成功获取用户', async () => {
const mockUser = { id: '1', name: '张三' };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await fetchUser('1');
expect(user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/users/1');
});
it('请求失败时抛出错误', async () => {
(global.fetch as any).mockResolvedValueOnce({ ok: false });
await expect(fetchUser('1')).rejects.toThrow('获取用户失败');
});
});
步骤 4:Mock 数据获取
app/components/UserList.tsx:
'use client';
import { useEffect, useState } from 'react';
interface User {
id: number;
name: string;
}
export default function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(data => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return <p>加载中…</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
app/components/UserList.test.tsx:
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
global.fetch = vi.fn();
describe('UserList', () => {
it('显示加载状态后渲染用户列表', async () => {
(global.fetch as any).mockResolvedValueOnce({
json: async () => [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
],
});
render(<UserList />);
expect(screen.getByText('加载中…')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.getByText('李四')).toBeInTheDocument();
});
});
});
步骤 5:安装 Playwright
npm install -D @playwright/test
npx playwright install
playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
});
步骤 6:E2E 测试
e2e/home.spec.ts:
import { test, expect } from '@playwright/test';
test('首页有标题', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('我的第一个 Next.js 页面');
});
test('导航到关于页面', async ({ page }) => {
await page.goto('/');
await page.click('text=关于');
await expect(page).toHaveURL('/about');
await expect(page.locator('h1')).toContainText('关于我们');
});
e2e/dashboard.spec.ts:
import { test, expect } from '@playwright/test';
test('仪表盘加载用户列表', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('text=用户列表')).toBeVisible();
});
运行:
npx playwright test # 无头模式
npx playwright test --ui # 可视化模式
步骤 7:CI 配置
.github/workflows/test.yml:
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run test
- run: npx playwright install --with-deps
- run: npx playwright test
讲清楚要点
- Vitest 比 Jest 更适合现代项目:原生 ESM 支持、更快的冷启动、和 Vite 配置共享。Next.js 15 官方推荐 Vitest。
- Server Component 测试策略:Server Component 是 async 函数,直接在 Node.js 环境调用并断言返回值。不需要 jsdom。
- Client Component 测试策略:用 React Testing Library 渲染到 jsdom,模拟用户交互(click、input),断言 DOM 变化。
- Mock 策略:
global.fetch直接 Mock;数据库用内存 SQLite;第三方服务用 MSW(Mock Service Worker)。 - E2E 测试覆盖关键路径:不要试图覆盖所有页面,只测核心用户流程:注册→登录→核心操作→退出。
- CI 里的 Playwright 需要
--with-deps:安装浏览器依赖(如 libnss3),否则在 Ubuntu 上会启动失败。
你可能会懵的地方
-
Jest 和 Vitest 选哪个? 新项目选 Vitest。Jest 配置复杂、对 ESM 支持差;Vitest 开箱即用、速度快、和 Vite 生态一致。Next.js 15 官方示例已切到 Vitest。
-
Server Component 的测试需要 jsdom 吗? 不需要。Server Component 在 Node.js 环境执行,直接
await调用并断言返回值即可。只有 Client Component 才需要 jsdom 模拟浏览器。 -
怎么 Mock
next/navigation的useRouter? 用vi.mock('next/navigation'),返回自定义的push、replace等函数。 -
Playwright 测试要不要先启动 dev server? 不用。
playwright.config.ts里的webServer配置会自动启动 dev server,测试结束后自动关闭。 -
E2E 测试太慢怎么办? 并行执行(
--workers=4)、只跑关键路径、用test.describe.serial串行依赖测试、用storageState复用登录状态避免重复登录。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 用 Jest 配 Next.js 15 | 配置复杂,ESM 支持差 | 换 Vitest |
| 在 jsdom 里测 Server Component | 不需要 DOM 环境 | 直接调用 async 函数断言返回值 |
| E2E 测试覆盖所有页面 | 太慢,维护成本高 | 只覆盖核心用户流程 |
| 不 Mock fetch,直接打真实 API | 测试不稳定、慢 | global.fetch = vi.fn() |
| 在 CI 里忘了装 Playwright 依赖 | 浏览器启动失败 | npx playwright install --with-deps |
卡点急救
- Vitest 报
Cannot find module '@/':在vitest.config.ts里配置resolve.alias,把@映射到项目根目录。 - 测试里
fetch是 undefined:在vitest.setup.ts里加global.fetch = vi.fn(),或在每个测试文件里单独 Mock。 - Playwright 启动浏览器失败:CI 环境加
--with-deps;本地确认浏览器已安装npx playwright install。 - Server Component 测试报
async/await语法错误:确保 Vitest 版本支持 top-level await,或把测试写成it('...', async () => { ... })。 - E2E 测试超时:增加
test.setTimeout(60000);检查 dev server 是否正常启动;用--debug模式查看具体卡在哪一步。
面试常问
-
Next.js 项目怎么做单元测试? Client Component 用 Vitest + React Testing Library,模拟交互断言 DOM;Server Component 直接调用 async 函数断言返回值;数据获取层 Mock fetch 或数据库。
-
E2E 测试和单元测试怎么平衡? 单元测试覆盖组件逻辑、工具函数,量大、快、稳定;E2E 测试覆盖核心用户路径,量小、慢、可能不稳定。推荐 70% 单元 + 20% 集成 + 10% E2E。
-
怎么测试动态路由页面? 单测:直接调用页面组件,传入 mock 的
params;E2E:用 Playwright 访问具体 URL,断言页面内容。 -
Mock 外部 API 有哪些方案? ①
global.fetch = vi.fn()最轻量;② MSW(Mock Service Worker)拦截网络层,更接近真实;③ 测试环境启动 mock server(如 json-server)。
自测三问
- Server Component 的测试为什么不需要 jsdom?
- Playwright 的
webServer配置解决了什么问题? - 什么场景下应该用 MSW 而不是直接 Mock fetch?
小结一句话
Next.js 测试的核心是「分层 Mock」:组件层用 RTL 断言渲染,数据层 Mock fetch 隔离外部依赖,E2E 层用 Playwright 验证真实流程——三层配合,既快又稳。
延伸
- Next.js 官方 - Testing
- Vitest 官方文档
- Playwright 官方文档
- 下一课:部署——Vercel 一键部署、Docker 容器化、环境变量管理、CI/CD 流水线、ISR 预热、自定义服务器。