本章目标:建立 Next.js App Router 的完整测试体系——使用 Vitest 做单元测试、React Testing Library 做组件测试、MSW 做 API Mock、Playwright 做 E2E 测试,并配置覆盖率、CI 集成与测试最佳实践。
19.1 测试金字塔
三层测试体系
E2E 测试
╱ ╲
╱ 集成测试 ╲ 少量(10-20%)
╱ ╲ 慢、贵、端到端验证
╱ ╲
╱ 组件测试 / 单元测试 ╲ 大量(80-90%)
╱______________________________╲ 快、便宜、隔离验证
| 测试类型 | 工具 | 速度 | 关注点 |
|---|---|---|---|
| 单元测试 | Vitest | 毫秒级 | 纯函数、工具函数、业务逻辑 |
| 组件测试 | RTL + Vitest | 百毫秒级 | 组件渲染、交互、状态 |
| 集成测试 | RTL + MSW | 秒级 | 多组件协作、API 交互 |
| E2E 测试 | Playwright | 秒级 | 完整用户流程、跨系统 |
测试覆盖率目标
核心业务逻辑: ≥ 80%
工具函数: ≥ 90%
UI 组件: ≥ 70%
E2E 关键路径: 100%(登录、购买、发布等)
19.2 Vitest 安装与配置
安装
npm install -D vitest @vitejs/plugin-react
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D jsdom msw@latest
Vitest 配置
// 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: ['./tests/setup.ts'],
include: [
'**/*.{test,spec}.{js,jsx,ts,tsx}',
],
exclude: [
'node_modules',
'.next',
'e2e', // E2E 测试用 Playwright
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
exclude: [
'node_modules/',
'tests/',
'e2e/',
'**/*.d.ts',
'**/*.config.*',
'app/layout.tsx',
'app/**/loading.tsx',
'app/**/error.tsx',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
});
测试环境设置
// tests/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// 每次测试后清理 DOM
afterEach(() => {
cleanup();
});
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
redirect: vi.fn(),
notFound: vi.fn(),
}));
// Mock next/headers
vi.mock('next/headers', () => ({
cookies: () => ({
get: vi.fn(),
set: vi.fn(),
}),
headers: () => new Headers(),
}));
// Mock next/cache
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: vi.fn((fn) => fn),
}));
// Mock next/image
vi.mock('next/image', () => ({
default: (props: any) => {
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
return <img {...props} />;
},
}));
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
package.json 脚本
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test:run && npm run test:e2e"
}
}
19.3 单元测试
工具函数测试
// lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { cn, formatDate, slugify, truncate } from './utils';
describe('cn()', () => {
it('should merge class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar');
});
it('should handle conditional classes', () => {
expect(cn('base', true && 'active', false && 'hidden')).toBe('base active');
});
it('should merge tailwind classes correctly', () => {
// tailwind-merge 应该正确处理冲突
expect(cn('px-4 py-2', 'px-6')).toBe('py-2 px-6');
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
});
it('should handle undefined and null', () => {
expect(cn('base', undefined, null, 'extra')).toBe('base extra');
});
});
describe('formatDate()', () => {
it('should format date in Chinese locale', () => {
const date = new Date('2025-06-11T10:30:00Z');
const formatted = formatDate(date);
expect(formatted).toContain('2025');
expect(formatted).toContain('6');
expect(formatted).toContain('11');
});
it('should return empty string for invalid date', () => {
expect(formatDate('invalid')).toBe('');
});
});
describe('slugify()', () => {
it('should convert string to slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
expect(slugify('Next.js 完全指南')).toMatch(/^[a-z0-9-]+$/);
});
it('should remove special characters', () => {
expect(slugify('Hello, World!')).toBe('hello-world');
});
it('should handle multiple spaces and dashes', () => {
expect(slugify('hello world')).toBe('hello-world');
expect(slugify('hello---world')).toBe('hello-world');
});
});
describe('truncate()', () => {
it('should truncate long strings', () => {
expect(truncate('Hello World', 5)).toBe('Hello...');
});
it('should not truncate short strings', () => {
expect(truncate('Hi', 10)).toBe('Hi');
});
it('should handle custom suffix', () => {
expect(truncate('Hello World', 5, '…')).toBe('Hello…');
});
});
业务逻辑测试
// lib/services/article-utils.test.ts
import { describe, it, expect } from 'vitest';
import {
calculateReadTime,
extractExcerpt,
generateTOC,
} from './article-utils';
describe('calculateReadTime()', () => {
it('should calculate read time based on 500 chars/min', () => {
const content = 'a'.repeat(1000);
expect(calculateReadTime(content)).toBe(2);
});
it('should round up partial minutes', () => {
const content = 'a'.repeat(750);
expect(calculateReadTime(content)).toBe(2);
});
it('should return 1 for very short content', () => {
expect(calculateReadTime('Hello')).toBe(1);
});
});
describe('extractExcerpt()', () => {
it('should extract first paragraph as excerpt', () => {
const content = 'First paragraph.\n\nSecond paragraph.';
expect(extractExcerpt(content)).toBe('First paragraph.');
});
it('should truncate to max length', () => {
const content = 'a'.repeat(500);
expect(extractExcerpt(content, 100).length).toBeLessThanOrEqual(103); // + "..."
});
it('should strip markdown syntax', () => {
const content = '# Title\n\n**Bold** and _italic_ text.';
const excerpt = extractExcerpt(content);
expect(excerpt).not.toContain('#');
expect(excerpt).not.toContain('**');
expect(excerpt).not.toContain('_');
});
});
describe('generateTOC()', () => {
it('should generate table of contents from headings', () => {
const content = `
# Title
## Section 1
### Subsection 1.1
## Section 2
`.trim();
const toc = generateTOC(content);
expect(toc).toHaveLength(3);
expect(toc[0]).toEqual({
id: 'section-1',
text: 'Section 1',
level: 2,
});
expect(toc[1]).toEqual({
id: 'subsection-11',
text: 'Subsection 1.1',
level: 3,
});
});
it('should handle empty content', () => {
expect(generateTOC('')).toEqual([]);
});
});
Zod Schema 测试
// lib/validators/article.test.ts
import { describe, it, expect } from 'vitest';
import { createArticleSchema } from './article';
describe('createArticleSchema', () => {
it('should validate correct data', () => {
const result = createArticleSchema.safeParse({
title: 'Test Article',
slug: 'test-article',
content: 'This is the content of the test article, at least 10 chars.',
category: 'frontend',
published: false,
});
expect(result.success).toBe(true);
});
it('should reject empty title', () => {
const result = createArticleSchema.safeParse({
title: '',
slug: 'test',
content: 'Content here',
category: 'frontend',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain('title');
}
});
it('should reject invalid slug format', () => {
const result = createArticleSchema.safeParse({
title: 'Test',
slug: 'Invalid Slug', // 包含空格和大写
content: 'Content here',
category: 'frontend',
});
expect(result.success).toBe(false);
});
it('should reject short content', () => {
const result = createArticleSchema.safeParse({
title: 'Test',
slug: 'test',
content: 'Short', // 少于 10 字符
category: 'frontend',
});
expect(result.success).toBe(false);
});
it('should reject invalid category', () => {
const result = createArticleSchema.safeParse({
title: 'Test',
slug: 'test',
content: 'Long enough content',
category: 'invalid',
});
expect(result.success).toBe(false);
});
});
19.4 组件测试(React Testing Library)
基础组件测试
// components/ui/__tests__/button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from '../button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('applies variant classes', () => {
render(<Button variant="destructive">Delete</Button>);
const button = screen.getByRole('button');
expect(button.className).toContain('bg-destructive');
});
it('applies size classes', () => {
render(<Button size="lg">Large</Button>);
const button = screen.getByRole('button');
expect(button.className).toContain('h-10');
});
it('handles click events', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Disabled
</Button>
);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
it('forwards ref correctly', () => {
const ref = vi.fn();
render(<Button ref={ref}>Ref</Button>);
expect(ref).toHaveBeenCalled();
});
});
业务组件测试
// components/article/__tests__/article-card.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ArticleCard } from '../article-card';
const mockArticle = {
id: '1',
title: 'Test Article',
slug: 'test-article',
excerpt: 'This is a test article excerpt.',
coverImage: '/test.jpg',
category: 'frontend',
tags: ['nextjs', 'react'],
createdAt: '2025-06-11T00:00:00Z',
author: {
id: 'u1',
name: 'Test Author',
avatar: '/avatar.jpg',
},
_count: {
comments: 5,
likes: 10,
},
};
describe('ArticleCard', () => {
it('renders article title', () => {
render(<ArticleCard article={mockArticle} />);
expect(screen.getByText('Test Article')).toBeInTheDocument();
});
it('renders article excerpt', () => {
render(<ArticleCard article={mockArticle} />);
expect(screen.getByText('This is a test article excerpt.')).toBeInTheDocument();
});
it('renders author name', () => {
render(<ArticleCard article={mockArticle} />);
expect(screen.getByText('Test Author')).toBeInTheDocument();
});
it('renders category badge', () => {
render(<ArticleCard article={mockArticle} />);
expect(screen.getByText('frontend')).toBeInTheDocument();
});
it('renders comment and like counts', () => {
render(<ArticleCard article={mockArticle} />);
expect(screen.getByText(/5/)).toBeInTheDocument();
expect(screen.getByText(/10/)).toBeInTheDocument();
});
it('links to the article slug', () => {
render(<ArticleCard article={mockArticle} />);
const link = screen.getByRole('link', { name: /Test Article/i });
expect(link).toHaveAttribute('href', '/articles/test-article');
});
it('renders formatted date', () => {
render(<ArticleCard article={mockArticle} />);
// 根据 formatDate 实现调整
expect(screen.getByText(/2025/)).toBeInTheDocument();
});
});
用户交互测试
// components/article/__tests__/like-button.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LikeButton } from '../like-button';
// Mock Server Action
vi.mock('@/app/actions/like', () => ({
toggleLike: vi.fn().mockResolvedValue(undefined),
}));
describe('LikeButton', () => {
const articleId = 'test-article-id';
beforeEach(() => {
vi.clearAllMocks();
});
it('renders initial like count', () => {
render(<LikeButton articleId={articleId} initialCount={10} />);
expect(screen.getByText(/10/)).toBeInTheDocument();
});
it('toggles like state on click', async () => {
const user = userEvent.setup();
render(<LikeButton articleId={articleId} initialCount={10} />);
const button = screen.getByRole('button');
await user.click(button);
// 乐观更新后,计数应该 +1
await waitFor(() => {
expect(screen.getByText(/11/)).toBeInTheDocument();
});
});
it('calls toggleLike action on click', async () => {
const { toggleLike } = await import('@/app/actions/like');
const user = userEvent.setup();
render(<LikeButton articleId={articleId} initialCount={10} />);
await user.click(screen.getByRole('button'));
expect(toggleLike).toHaveBeenCalledWith(articleId);
});
it('reverts on action failure', async () => {
const { toggleLike } = await import('@/app/actions/like');
(toggleLike as any).mockRejectedValueOnce(new Error('Failed'));
const user = userEvent.setup();
render(<LikeButton articleId={articleId} initialCount={10} />);
await user.click(screen.getByRole('button'));
// 失败后应该回滚
await waitFor(() => {
expect(screen.getByText(/10/)).toBeInTheDocument();
});
});
});
表单测试
// components/forms/__tests__/contact-form.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ContactForm } from '../contact-form';
describe('ContactForm', () => {
it('renders all form fields', () => {
render(<ContactForm />);
expect(screen.getByLabelText(/姓名/)).toBeInTheDocument();
expect(screen.getByLabelText(/邮箱/)).toBeInTheDocument();
expect(screen.getByLabelText(/主题/)).toBeInTheDocument();
expect(screen.getByLabelText(/内容/)).toBeInTheDocument();
});
it('validates required fields', async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.click(screen.getByRole('button', { name: /提交/ }));
await waitFor(() => {
expect(screen.getByText(/姓名至少/)).toBeInTheDocument();
expect(screen.getByText(/邮箱/)).toBeInTheDocument();
});
});
it('validates email format', async () => {
const user = userEvent.setup();
render(<ContactForm />);
const emailInput = screen.getByLabelText(/邮箱/);
await user.type(emailInput, 'invalid-email');
await user.click(screen.getByRole('button', { name: /提交/ }));
await waitFor(() => {
expect(screen.getByText(/邮箱格式不正确/)).toBeInTheDocument();
});
});
it('submits valid form data', async () => {
const user = userEvent.setup();
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
render(<ContactForm />);
await user.type(screen.getByLabelText(/姓名/), '张三');
await user.type(screen.getByLabelText(/邮箱/), 'zhangsan@example.com');
await user.type(screen.getByLabelText(/主题/), '测试主题');
await user.type(screen.getByLabelText(/内容/), '这是至少十个字符的测试内容,应该通过验证。');
await user.click(screen.getByRole('button', { name: /提交/ }));
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Submitted:',
expect.objectContaining({
name: '张三',
email: 'zhangsan@example.com',
subject: '测试主题',
})
);
});
consoleSpy.mockRestore();
});
});
19.5 MSW(Mock Service Worker)集成测试
MSW 安装与配置
npx msw@latest init ./public --save
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Mock 文章 API
http.get('/api/v1/articles', () => {
return HttpResponse.json({
data: [
{
id: '1',
title: 'Mock Article',
slug: 'mock-article',
excerpt: 'This is a mock article.',
createdAt: '2025-06-11T00:00:00Z',
},
],
pagination: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
},
});
}),
// Mock 登录 API
http.post('/api/v1/auth/login', async ({ request }) => {
const body = await request.json() as any;
if (body.email === 'test@example.com' && body.password === 'password') {
return HttpResponse.json({
token: 'mock-jwt-token',
user: { id: '1', email: 'test@example.com', name: 'Test User' },
});
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
// Mock 外部 API
http.get('https://api.example.com/data', () => {
return HttpResponse.json({ result: 'mocked' });
}),
];
// tests/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// tests/setup.ts
import { server } from './mocks/server';
import { beforeAll, afterEach, afterAll } from 'vitest';
// 启动 MSW
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
集成测试示例
// app/articles/__tests__/page.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '@/tests/mocks/server';
import ArticlesPage from '../page';
describe('ArticlesPage', () => {
it('renders article list', async () => {
render(<ArticlesPage searchParams={Promise.resolve({})} />);
await waitFor(() => {
expect(screen.getByText('Mock Article')).toBeInTheDocument();
});
});
it('handles API error gracefully', async () => {
server.use(
http.get('/api/v1/articles', () => {
return HttpResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
})
);
render(<ArticlesPage searchParams={Promise.resolve({})} />);
await waitFor(() => {
expect(screen.getByText(/加载失败/)).toBeInTheDocument();
});
});
it('shows empty state when no articles', async () => {
server.use(
http.get('/api/v1/articles', () => {
return HttpResponse.json({
data: [],
pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
});
})
);
render(<ArticlesPage searchParams={Promise.resolve({})} />);
await waitFor(() => {
expect(screen.getByText(/暂无文章/)).toBeInTheDocument();
});
});
});
19.6 Playwright E2E 测试
安装
npm install -D @playwright/test
npx playwright install --with-deps chromium
配置
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['html'], ['github']]
: [['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
基础 E2E 测试
// e2e/home.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Home Page', () => {
test('should display hero section', async ({ page }) => {
await page.goto('/');
await expect(
page.getByRole('heading', { name: /Next\.js/ })
).toBeVisible();
});
test('should navigate to articles page', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: /文章/ }).click();
await expect(page).toHaveURL('/articles');
await expect(page.getByRole('heading', { name: /文章列表/ })).toBeVisible();
});
test('should have working theme toggle', async ({ page }) => {
await page.goto('/');
// 点击主题切换
await page.getByRole('button', { name: /🌙/ }).click();
// 验证暗色模式
await expect(page.locator('html')).toHaveClass(/dark/);
});
test('should have correct SEO metadata', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/MyBlog/);
await expect(page.locator('meta[name="description"]')).toHaveAttribute(
'content',
/Next\.js/
);
});
});
登录流程测试
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test.beforeEach(async ({ page }) => {
// 访问登录页
await page.goto('/login');
});
test('should show validation errors on empty submit', async ({ page }) => {
await page.getByRole('button', { name: /登录/ }).click();
await expect(page.getByText(/邮箱/)).toBeVisible();
});
test('should login successfully with valid credentials', async ({ page }) => {
await page.getByLabel(/邮箱/).fill('test@example.com');
await page.getByLabel(/密码/).fill('password123');
await page.getByRole('button', { name: /登录/ }).click();
// 应该跳转到 dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(/欢迎/)).toBeVisible();
});
test('should show error on invalid credentials', async ({ page }) => {
await page.getByLabel(/邮箱/).fill('wrong@example.com');
await page.getByLabel(/密码/).fill('wrongpassword');
await page.getByRole('button', { name: /登录/ }).click();
await expect(page.getByText(/邮箱或密码错误/)).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('should register new user', async ({ page }) => {
await page.goto('/register');
await page.getByLabel(/昵称/).fill('NewUser');
await page.getByLabel(/邮箱/).fill('new@example.com');
await page.getByLabel(/密码/).fill('password123');
await page.getByRole('button', { name: /注册/ }).click();
await expect(page).toHaveURL('/dashboard');
});
test('should logout successfully', async ({ page, context }) => {
// 先登录
await page.getByLabel(/邮箱/).fill('test@example.com');
await page.getByLabel(/密码/).fill('password123');
await page.getByRole('button', { name: /登录/ }).click();
await expect(page).toHaveURL('/dashboard');
// 登出
await page.getByRole('button', { name: /退出/ }).click();
await expect(page).toHaveURL('/');
// 验证 cookie 已清除
const cookies = await context.cookies();
const authCookie = cookies.find((c) => c.name === 'auth-token');
expect(authCookie).toBeUndefined();
});
});
文章发布流程测试
// e2e/article.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Article Management', () => {
// 复用登录状态
test.use({
storageState: 'e2e/.auth/user.json',
});
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard/articles/new');
});
test('should create new article', async ({ page }) => {
// 填写表单
await page.getByLabel(/标题/).fill('My Test Article');
await page.getByLabel(/Slug/).fill('my-test-article');
await page.getByLabel(/内容/).fill('This is the content of my test article. It has more than ten characters.');
await page.getByLabel(/分类/).selectOption('frontend');
// 提交
await page.getByRole('button', { name: /保存/ }).click();
// 等待跳转
await expect(page).toHaveURL('/dashboard/articles');
await expect(page.getByText('My Test Article')).toBeVisible();
});
test('should validate form before submit', async ({ page }) => {
// 直接提交空表单
await page.getByRole('button', { name: /保存/ }).click();
// 应该显示验证错误
await expect(page.getByText(/标题不能为空/)).toBeVisible();
await expect(page.getByText(/内容至少/)).toBeVisible();
});
test('should auto-generate slug from title', async ({ page }) => {
await page.getByLabel(/标题/).fill('Hello World Test');
// Slug 应该自动生成
const slugInput = page.getByLabel(/Slug/);
await expect(slugInput).toHaveValue('hello-world-test');
});
test('should edit existing article', async ({ page }) => {
// 先进入编辑页
await page.goto('/dashboard/articles');
await page.getByRole('link', { name: /My Test Article/ }).click();
// 修改标题
await page.getByLabel(/标题/).fill('Updated Title');
await page.getByRole('button', { name: /保存/ }).click();
await expect(page.getByText('Updated Title')).toBeVisible();
});
test('should delete article', async ({ page }) => {
await page.goto('/dashboard/articles');
// 打开操作菜单
await page.getByRole('button', { name: /⋯/ }).first().click();
await page.getByRole('menuitem', { name: /删除/ }).click();
// 确认删除
page.on('dialog', (dialog) => dialog.accept());
await expect(page.getByText(/删除成功/)).toBeVisible();
});
});
全局 Auth Setup
// e2e/global-setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate as user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/邮箱/).fill('test@example.com');
await page.getByLabel(/密码/).fill('password123');
await page.getByRole('button', { name: /登录/ }).click();
await expect(page).toHaveURL('/dashboard');
// 保存认证状态
await page.context().storageState({ path: authFile });
});
19.7 Server Action 测试
// app/actions/__tests__/article.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Prisma
vi.mock('@/lib/prisma', () => ({
prisma: {
article: {
create: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// Mock auth
vi.mock('@/lib/auth-utils', () => ({
requireAuth: vi.fn().mockResolvedValue({
id: 'user-1',
email: 'test@example.com',
role: 'admin',
}),
}));
// Mock cache
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { createArticle, deleteArticle } from '../article';
import { prisma } from '@/lib/prisma';
describe('createArticle', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates article with valid data', async () => {
const mockArticle = { id: '1', title: 'Test', slug: 'test' };
(prisma.article.create as any).mockResolvedValue(mockArticle);
(prisma.article.findUnique as any).mockResolvedValue(null);
const result = await createArticle({}, {
title: 'Test',
slug: 'test',
content: 'Long enough content for testing',
category: 'frontend',
published: false,
});
expect(result.success).toBe(true);
expect(prisma.article.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
title: 'Test',
authorId: 'user-1',
}),
})
);
});
it('rejects duplicate slug', async () => {
(prisma.article.findUnique as any).mockResolvedValue({ id: 'existing' });
const result = await createArticle({}, {
title: 'Test',
slug: 'existing-slug',
content: 'Long enough content',
category: 'frontend',
});
expect(result.fieldErrors?.slug).toBeDefined();
expect(prisma.article.create).not.toHaveBeenCalled();
});
it('validates input data', async () => {
const result = await createArticle({}, {
title: '',
slug: 'invalid slug',
content: 'short',
category: 'invalid',
} as any);
expect(result.fieldErrors).toBeDefined();
expect(prisma.article.create).not.toHaveBeenCalled();
});
});
describe('deleteArticle', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('deletes article owned by user', async () => {
(prisma.article.findUnique as any).mockResolvedValue({
id: '1',
slug: 'test',
authorId: 'user-1',
});
const result = await deleteArticle('1');
expect(result.success).toBe(true);
expect(prisma.article.delete).toHaveBeenCalledWith({
where: { id: '1' },
});
});
it('rejects deletion of other user articles', async () => {
(prisma.article.findUnique as any).mockResolvedValue({
id: '1',
slug: 'test',
authorId: 'other-user',
});
const result = await deleteArticle('1');
expect(result.error).toBeDefined();
expect(prisma.article.delete).not.toHaveBeenCalled();
});
});
19.8 测试辅助工具
自定义 render 函数
// tests/test-utils.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { SessionProvider } from 'next-auth/react';
// 模拟 session
const mockSession = {
user: {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
role: 'user',
},
expires: '2025-12-31T00:00:00Z',
};
// 提供所有必要的 Provider
function AllProviders({ children }: { children: ReactNode }) {
return (
<SessionProvider session={mockSession}>
{children}
</SessionProvider>
);
}
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// 未认证的 render
function renderWithoutAuth(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, options);
}
export * from '@testing-library/react';
export { customRender as render, renderWithoutAuth };
使用方式
// 自动包含所有 Provider
import { render, screen } from '@/tests/test-utils';
it('renders user info', () => {
render(<UserProfile />);
expect(screen.getByText('Test User')).toBeInTheDocument();
});
Mock 数据工厂
// tests/factories.ts
import { faker } from '@faker-js/faker';
export function createMockUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
avatar: faker.image.avatar(),
bio: faker.lorem.paragraph(),
role: 'USER',
password: faker.internet.password(),
emailVerified: new Date(),
isActive: true,
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
...overrides,
};
}
export function createMockArticle(overrides: Partial<Article> = {}): Article {
return {
id: faker.string.uuid(),
title: faker.lorem.sentence(),
slug: faker.lorem.slug(),
content: faker.lorem.paragraphs(5),
excerpt: faker.lorem.paragraph(),
coverImage: faker.image.url(),
category: faker.helpers.arrayElement(['frontend', 'backend', 'devops']),
tags: faker.helpers.arrayElements(['react', 'nextjs', 'typescript'], 3),
published: true,
featured: false,
views: faker.number.int({ min: 0, max: 1000 }),
readTime: faker.number.int({ min: 1, max: 20 }),
authorId: faker.string.uuid(),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
publishedAt: faker.date.recent(),
...overrides,
};
}
19.9 CI/CD 集成
GitHub Actions
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-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
- run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
e2e-test:
runs-on: ubuntu-latest
needs: unit-test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Start database
run: docker-compose up -d postgres
- name: Run migrations
run: npx prisma migrate deploy
- name: Seed test data
run: npx prisma db seed
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
19.10 测试最佳实践
## 测试最佳实践清单
### 测试命名
- [ ] 使用描述性的测试名称(应该... 当...)
- [ ] 按功能模块分组(describe)
- [ ] 测试文件与源文件同名(foo.ts → foo.test.ts)
### 测试结构
- [ ] 每个测试只验证一个行为
- [ ] 使用 AAA 模式(Arrange-Act-Assert)
- [ ] 避免测试之间的依赖和顺序
### Mock 策略
- [ ] 只 mock 外部依赖(API、数据库、文件系统)
- [ ] 不 mock 待测试的代码本身
- [ ] 使用 MSW 替代手动 mock fetch
### 组件测试
- [ ] 通过角色查询元素(getByRole > getByText > getByTestId)
- [ ] 测试用户行为而非实现细节
- [ ] 避免直接访问组件内部状态
### E2E 测试
- [ ] 只覆盖关键用户路径
- [ ] 使用 data-testid 定位元素(UI 变化不影响测试)
- [ ] 避免测试第三方库的行为
### 持续改进
- [ ] 每次修 bug 都添加对应的测试
- [ ] 定期审查测试覆盖率报告
- [ ] 删除不再相关的测试
本章小结
Key Takeaways
- 测试金字塔是测试策略的基础:单元测试大量、E2E 测试少量
- Vitest 是 Next.js 单元测试的首选:快、兼容 Vite、类型安全
- RTL 测试用户行为而非实现:
getByRole>getByText>getByTestId - MSW 是 API Mock 的标准方案:比手动 mock fetch 更贴近真实
- Playwright 是 E2E 测试的最佳工具:跨浏览器、稳定、API 友好
- 测试是投资而非成本:每次修 bug 都添加测试,长期受益
下一步
下一章我们将深入 错误处理与监控——构建 Next.js 的完整错误处理体系,包括 Error Boundary、全局错误页、日志记录、Sentry 集成与生产监控。
参考资料
- Vitest 官方文档
- React Testing Library
- MSW (Mock Service Worker)
- Playwright 官方文档
- Next.js 测试指南
- Testing Library 查询优先级
- faker.js
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。