Next.jsTestingJestPlaywrightCI/CD
自动化测试在 Next.js 项目中的最佳实践:从单元到端到端
2026-03-07
自动化测试在 Next.js 项目中的最佳实践:从单元到端到端
在现代 Web 开发中,自动化测试是保证代码质量和项目可维护性的关键。Next.js 作为流行的 React 框架,其混合渲染特性(SSR/SSG/CSR)为测试带来了独特的挑战。本文将分享在 Next.js 项目中实施自动化测试的完整策略。
测试金字塔与策略
遵循测试金字塔原则,我们的测试策略应该是:
/\
/E2E\ ← 少量,覆盖关键用户流程
/------\
/集成测试\ ← 中等数量,测试组件交互
/----------\
/ 单元测试 \ ← 大量,快速反馈
/--------------\
- 单元测试(70%):快速、隔离、大量
- 集成测试(20%):组件协作、API 交互
- E2E 测试(10%):关键路径、用户场景
单元测试:Jest + React Testing Library
环境配置
首先安装必要的依赖:
npm install -D jest @testing-library/react @testing-library/jest-dom \
@testing-library/user-event jest-environment-jsdom
配置 jest.config.js:
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
}
module.exports = createJestConfig(customJestConfig)
组件测试示例
测试一个典型的 Next.js 组件:
// components/ArticleCard.tsx
import Link from 'next/link';
import Image from 'next/image';
interface ArticleCardProps {
title: string;
excerpt: string;
slug: string;
coverImage?: string;
}
export function ArticleCard({ title, excerpt, slug, coverImage }: ArticleCardProps) {
return (
<article className="article-card">
{coverImage && (
<Image src={coverImage} alt={title} width={400} height={250} />
)}
<h2>{title}</h2>
<p>{excerpt}</p>
<Link href={`/articles/${slug}`}>阅读更多</Link>
</article>
);
}
对应的测试文件:
// components/ArticleCard.test.tsx
import { render, screen } from '@testing-library/react';
import { ArticleCard } from './ArticleCard';
describe('ArticleCard', () => {
const mockProps = {
title: 'Test Article',
excerpt: 'This is a test excerpt',
slug: 'test-article',
};
it('renders article information correctly', () => {
render(<ArticleCard {...mockProps} />);
expect(screen.getByRole('heading', { name: 'Test Article' })).toBeInTheDocument();
expect(screen.getByText('This is a test excerpt')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '阅读更多' })).toHaveAttribute(
'href',
'/articles/test-article'
);
});
it('renders cover image when provided', () => {
render(<ArticleCard {...mockProps} coverImage="/test.jpg" />);
const image = screen.getByAlt('Test Article');
expect(image).toBeInTheDocument();
});
it('does not render image when coverImage is not provided', () => {
render(<ArticleCard {...mockProps} />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
});
测试 Server Components
Next.js 13+ 的 Server Components 需要特殊处理:
// app/components/ServerArticleList.test.tsx
import { render, screen } from '@testing-library/react';
import { ServerArticleList } from './ServerArticleList';
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([
{ id: 1, title: 'Article 1' },
{ id: 2, title: 'Article 2' },
]),
})
) as jest.Mock;
describe('ServerArticleList', () => {
it('fetches and displays articles', async () => {
const { container } = render(await ServerArticleList());
expect(screen.getByText('Article 1')).toBeInTheDocument();
expect(screen.getByText('Article 2')).toBeInTheDocument();
});
});
集成测试:API Routes 与数据流
测试 API Routes
Next.js API Routes 的测试需要模拟请求和响应:
// pages/api/articles.test.ts
import { createMocks } from 'node-mocks-http';
import handler from './articles';
describe('/api/articles', () => {
it('returns articles list', async () => {
const { req, res } = createMocks({
method: 'GET',
});
await handler(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
title: expect.any(String),
}),
])
);
});
it('handles POST requests', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
title: 'New Article',
content: 'Article content',
},
});
await handler(req, res);
expect(res._getStatusCode()).toBe(201);
});
it('returns 405 for unsupported methods', async () => {
const { req, res } = createMocks({
method: 'DELETE',
});
await handler(req, res);
expect(res._getStatusCode()).toBe(405);
});
});
测试数据获取
使用 MSW (Mock Service Worker) 模拟 API 响应:
// mocks/handlers.ts
import { rest } from 'msw';
export const handlers = [
rest.get('/api/articles', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, title: 'Mocked Article' },
])
);
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
在测试中使用:
// jest.setup.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
E2E 测试:Playwright
配置 Playwright
安装并初始化:
npm init playwright@latest
配置 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: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 13'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
E2E 测试示例
测试完整的用户流程:
// e2e/article-workflow.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Article Workflow', () => {
test('user can browse and read articles', async ({ page }) => {
// 访问首页
await page.goto('/');
// 验证文章列表加载
await expect(page.locator('article')).toHaveCount(10);
// 点击第一篇文章
await page.locator('article').first().click();
// 验证文章详情页
await expect(page).toHaveURL(/\/articles\/.+/);
await expect(page.locator('h1')).toBeVisible();
// 验证相关文章推荐
await expect(page.locator('.related-articles')).toBeVisible();
});
test('search functionality works correctly', async ({ page }) => {
await page.goto('/');
// 输入搜索关键词
await page.fill('input[type="search"]', 'Next.js');
await page.press('input[type="search"]', 'Enter');
// 等待搜索结果
await page.waitForURL(/\/search\?q=Next\.js/);
// 验证搜索结果
const results = page.locator('.search-result');
await expect(results).toHaveCountGreaterThan(0);
// 验证结果包含关键词
const firstResult = results.first();
await expect(firstResult).toContainText('Next.js', { ignoreCase: true });
});
});
CI/CD 集成
GitHub Actions 配置
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Run E2E tests
run: npx playwright test
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
最佳实践总结
- 测试命名清晰:使用描述性的测试名称,说明测试的内容和预期结果
- 避免测试实现细节:测试用户行为而非内部实现
- 使用测试 ID:为关键元素添加
data-testid属性,提高测试稳定性 - Mock 外部依赖:隔离外部 API 和服务,确保测试可靠性
- 保持测试独立:每个测试应该能够独立运行,不依赖其他测试
- 定期审查覆盖率:设置合理的覆盖率目标,但不要盲目追求 100%
- 快速反馈循环:优先运行单元测试,E2E 测试放在 CI 环境
性能优化技巧
- 并行执行:使用 Jest 的
--maxWorkers参数 - 智能缓存:利用
--onlyChanged只测试修改的文件 - 分片测试:在 CI 中将测试分配到多个 worker
- 避免 watch 模式:CI 环境使用
--run标志
通过系统化的测试策略和工具链,我们可以构建高质量、可维护的 Next.js 应用,确保每次发布都充满信心。
相关资源: