Next.js测试策略 #
一、测试概述 #
1.1 测试类型 #
| 类型 | 说明 | 工具 |
|---|---|---|
| 单元测试 | 测试单个函数/组件 | Jest/Vitest |
| 集成测试 | 测试组件交互 | Testing Library |
| E2E测试 | 测试完整流程 | Playwright |
1.2 测试金字塔 #
text
/\
/ \
/ E2E\
/------\
/ 集成测试 \
/----------\
/ 单元测试 \
/--------------\
二、Vitest配置 #
2.1 安装 #
bash
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react
2.2 配置 #
typescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './vitest.setup.ts',
},
})
2.3 setup文件 #
typescript
import '@testing-library/jest-dom'
2.4 package.json #
json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
}
}
三、单元测试 #
3.1 测试工具函数 #
typescript
import { describe, it, expect } from 'vitest'
import { formatDate, slugify } from './utils'
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2024-01-15')
expect(formatDate(date)).toBe('2024年1月15日')
})
it('should handle invalid date', () => {
expect(() => formatDate(new Date('invalid'))).toThrow()
})
})
describe('slugify', () => {
it('should convert to slug', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('should remove special characters', () => {
expect(slugify('Hello! @World#')).toBe('hello-world')
})
})
3.2 测试自定义Hook #
tsx
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('should increment count', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('should decrement count', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
})
3.3 测试组件 #
tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Button } from './Button'
describe('Button', () => {
it('should render correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('should handle click', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be disabled', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
})
四、集成测试 #
4.1 测试表单 #
tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('should submit form with valid data', async () => {
const onSuccess = vi.fn()
render(<LoginForm onSuccess={onSuccess} />)
fireEvent.change(screen.getByLabelText(/邮箱/i), {
target: { value: 'test@example.com' },
})
fireEvent.change(screen.getByLabelText(/密码/i), {
target: { value: 'password123' },
})
fireEvent.click(screen.getByText(/登录/i))
await waitFor(() => {
expect(onSuccess).toHaveBeenCalled()
})
})
it('should show error with invalid data', async () => {
render(<LoginForm />)
fireEvent.click(screen.getByText(/登录/i))
await waitFor(() => {
expect(screen.getByText(/请输入邮箱/i)).toBeInTheDocument()
})
})
})
4.2 测试列表 #
tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { UserList } from './UserList'
const mockUsers = [
{ id: '1', name: 'User 1', email: 'user1@example.com' },
{ id: '2', name: 'User 2', email: 'user2@example.com' },
]
describe('UserList', () => {
it('should render users', () => {
render(<UserList users={mockUsers} />)
expect(screen.getByText('User 1')).toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should show empty message', () => {
render(<UserList users={[]} />)
expect(screen.getByText(/暂无用户/i)).toBeInTheDocument()
})
})
五、E2E测试 #
5.1 安装Playwright #
bash
npm install -D @playwright/test
npx playwright install
5.2 配置 #
typescript
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
5.3 测试用例 #
typescript
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('欢迎')
})
test('should show error with invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'wrong@example.com')
await page.fill('input[name="password"]', 'wrongpassword')
await page.click('button[type="submit"]')
await expect(page.locator('.error-message')).toContainText('登录失败')
})
})
test.describe('Blog', () => {
test('should display blog posts', async ({ page }) => {
await page.goto('/blog')
await expect(page.locator('article')).toHaveCount(10)
})
test('should navigate to post detail', async ({ page }) => {
await page.goto('/blog')
await page.click('article:first-child a')
await expect(page).toHaveURL(/\/blog\/.+/)
})
})
六、Mock #
6.1 Mock API #
typescript
import { vi } from 'vitest'
global.fetch = vi.fn()
describe('API calls', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should fetch users', async () => {
const mockUsers = [{ id: '1', name: 'User 1' }]
;(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUsers,
})
const users = await getUsers()
expect(users).toEqual(mockUsers)
expect(fetch).toHaveBeenCalledWith('/api/users')
})
})
6.2 Mock Server Actions #
typescript
import { vi } from 'vitest'
vi.mock('./actions', () => ({
createPost: vi.fn(),
}))
describe('CreatePostForm', () => {
it('should call createPost on submit', async () => {
const createPost = vi.fn().mockResolvedValue({ success: true })
vi.mocked(createPost).mockImplementation(createPost)
render(<CreatePostForm />)
fireEvent.change(screen.getByLabelText(/标题/i), {
target: { value: 'Test Post' },
})
fireEvent.click(screen.getByText(/发布/i))
await waitFor(() => {
expect(createPost).toHaveBeenCalled()
})
})
})
七、最佳实践 #
7.1 测试命名 #
typescript
describe('Component', () => {
it('should do something when condition', () => {
// 测试代码
})
})
7.2 AAA模式 #
typescript
it('should increment counter', () => {
// Arrange
const { result } = renderHook(() => useCounter())
// Act
act(() => {
result.current.increment()
})
// Assert
expect(result.current.count).toBe(1)
})
7.3 测试覆盖 #
bash
npm run test:coverage
八、总结 #
测试策略要点:
| 要点 | 说明 |
|---|---|
| 单元测试 | Vitest |
| 组件测试 | Testing Library |
| E2E测试 | Playwright |
| Mock | vi.fn() |
| 覆盖率 | coverage |
恭喜你完成Next.js完全指南的学习!
最后更新:2026-03-28