Files
2026-01-26 08:04:53 +08:00

1021 lines
25 KiB
Markdown

# 前端测试技术文档
## 1. 测试架构概述
### 1.1 测试金字塔
```
/\
/ \ E2E测试
/ \ (Playwright, 最少)
/------\
/ \ 集成测试
/ \ (RTL, 适中)
/____________\
/ \ 单元测试
/________________\ (Vitest, 最多)
```
### 1.2 测试层级
| 测试类型 | 覆盖范围 | 比例 | 运行速度 | 工具 |
|---------|---------|------|---------|------|
| 单元测试 | 函数、组件、Hooks | 70% | 快 (< 1s) | Vitest |
| 集成测试 | 页面、路由、API交互 | 20% | 中 (< 5s) | React Testing Library |
| E2E测试 | 完整用户流程 | 10% | 慢 (< 30s) | Playwright |
## 2. 测试工具栈
### 2.1 单元测试
**Vitest**
- 快速的单元测试框架
- 与Vite深度集成
- 兼容Jest API
- 支持TypeScript
**安装**
```bash
npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
```
### 2.2 集成测试
**React Testing Library (RTL)**
- 测试React组件
- 关注用户行为而非实现细节
- 可访问性优先
### 2.3 E2E测试
**Playwright**
- 跨浏览器E2E测试
- 快速、可靠
- 支持并行执行
**安装**
```bash
npm install -D @playwright/test
npx playwright install
```
### 2.4 测试覆盖率
**Vitest覆盖率**
```bash
npm run test:coverage
```
**覆盖率目标**
- 语句覆盖率: > 80%
- 分支覆盖率: > 75%
- 函数覆盖率: > 80%
- 行覆盖率: > 80%
## 3. 测试配置
### 3.1 Vitest配置
```javascript
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.js',
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.config.js',
'**/*.config.jsx',
'**/dist/',
'**/build/',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
include: ['src/**/*.{test,spec}.{js,jsx}'],
testTimeout: 10000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
```
### 3.2 测试环境设置
```javascript
// tests/setup.js
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// 每个测试后清理
afterEach(() => {
cleanup();
});
// 模拟window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// 模拟localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: key => store[key] || null,
setItem: (key, value) => (store[key] = value.toString()),
removeItem: key => delete store[key],
clear: () => (store = {}),
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
```
### 3.3 Playwright配置
```javascript
// playwright.config.js
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'test-results/results.json' }],
],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});
```
## 4. 单元测试
### 4.1 测试工具函数
```javascript
// src/utils/format.test.js
import { describe, it, expect } from 'vitest';
import { formatMoney, formatPercentage, formatDate } from './format';
describe('formatMoney', () => {
it('应该正确格式化金额', () => {
expect(formatMoney(1234.56)).toBe('1,234.56');
expect(formatMoney(10000)).toBe('10,000.00');
});
it('应该处理小数', () => {
expect(formatMoney(100.5)).toBe('100.50');
expect(formatMoney(100.123)).toBe('100.12');
});
it('应该处理零', () => {
expect(formatMoney(0)).toBe('0.00');
});
it('应该处理负数', () => {
expect(formatMoney(-1234.56)).toBe('-1,234.56');
});
});
describe('formatPercentage', () => {
it('应该正确格式化百分比', () => {
expect(formatPercentage(0.5)).toBe('50.00%');
expect(formatPercentage(1)).toBe('100.00%');
});
it('应该保留两位小数', () => {
expect(formatPercentage(0.6666)).toBe('66.66%');
});
});
describe('formatDate', () => {
it('应该正确格式化日期', () => {
expect(formatDate('2026-01-25')).toBe('2026-01-25');
expect(formatDate(new Date('2026-01-25'))).toBe('2026-01-25');
});
});
```
### 4.2 测试自定义Hooks
```javascript
// src/hooks/useAuth.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { AuthProvider, useAuth } from '../contexts/AuthContext';
describe('useAuth Hook', () => {
beforeEach(() => {
// 清理localStorage
localStorage.clear();
});
it('初始状态应该是未认证', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBe(null);
expect(result.current.token).toBe(null);
});
it('应该正确处理登录', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
await act(async () => {
await result.current.login('admin', 'password123');
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toBeTruthy();
expect(result.current.token).toBeTruthy();
});
});
it('应该正确处理登出', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
// 先登录
await act(async () => {
await result.current.login('admin', 'password123');
});
// 然后登出
await act(async () => {
result.current.logout();
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBe(null);
expect(result.current.token).toBe(null);
});
});
it('hasRole应该正确检查角色', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
await act(async () => {
await result.current.login('admin', 'password123');
});
await waitFor(() => {
expect(result.current.hasRole(['admin'])).toBe(true);
expect(result.current.hasRole(['market'])).toBe(false);
expect(result.current.hasRole(['admin', 'market'])).toBe(true);
});
});
});
```
### 4.3 测试API服务
```javascript
// src/services/project.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { projectAPI } from './project';
import api from './api';
vi.mock('./api');
describe('projectAPI', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getList', () => {
it('应该调用正确的API', async () => {
const mockResponse = {
items: [{ id: 1, name: '项目1' }],
total: 1,
};
api.get.mockResolvedValue(mockResponse);
const params = { page: 1, page_size: 10 };
const result = await projectAPI.getList(params);
expect(api.get).toHaveBeenCalledWith('/projects', { params });
expect(result).toEqual(mockResponse);
});
});
describe('getDetail', () => {
it('应该获取项目详情', async () => {
const mockProject = { id: 1, name: '项目1' };
api.get.mockResolvedValue(mockProject);
const result = await projectAPI.getDetail(1);
expect(api.get).toHaveBeenCalledWith('/projects/1');
expect(result).toEqual(mockProject);
});
});
describe('create', () => {
it('应该创建新项目', async () => {
const mockProject = { id: 2, name: '新项目' };
api.post.mockResolvedValue(mockProject);
const data = { name: '新项目', contract_amount: 100 };
const result = await projectAPI.create(data);
expect(api.post).toHaveBeenCalledWith('/projects', data);
expect(result).toEqual(mockProject);
});
});
describe('update', () => {
it('应该更新项目', async () => {
const mockProject = { id: 1, name: '更新项目' };
api.put.mockResolvedValue(mockProject);
const data = { name: '更新项目' };
const result = await projectAPI.update(1, data);
expect(api.put).toHaveBeenCalledWith('/projects/1', data);
expect(result).toEqual(mockProject);
});
});
describe('delete', () => {
it('应该删除项目', async () => {
api.delete.mockResolvedValue({});
await projectAPI.delete(1);
expect(api.delete).toHaveBeenCalledWith('/projects/1');
});
});
});
```
## 5. 集成测试
### 5.1 测试页面组件
```javascript
// src/pages/Login/Login.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Login from './Login';
// 模拟authAPI
vi.mock('../../services/auth', () => ({
authAPI: {
login: vi.fn(),
},
}));
// 模拟useAuth Hook
vi.mock('../../hooks/useAuth', () => ({
useAuth: () => ({
login: vi.fn(),
loading: false,
}),
}));
describe('Login Page', () => {
const renderLogin = () => {
return render(
<BrowserRouter>
<Login />
</BrowserRouter>
);
};
it('应该渲染登录表单', () => {
renderLogin();
expect(screen.getByText('海洋项目管理系统')).toBeInTheDocument();
expect(screen.getByLabelText(/用户名/)).toBeInTheDocument();
expect(screen.getByLabelText(/密码/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /登录/ })).toBeInTheDocument();
});
it('应该显示必填字段验证错误', async () => {
renderLogin();
const submitButton = screen.getByRole('button', { name: /登录/ });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/请输入用户名/)).toBeInTheDocument();
expect(screen.getByText(/请输入密码/)).toBeInTheDocument();
});
});
it('应该成功登录', async () => {
const { login } = useAuth();
login.mockResolvedValue({
user: { id: 1, username: 'admin' },
token: 'mock-token',
});
renderLogin();
const usernameInput = screen.getByLabelText(/用户名/);
const passwordInput = screen.getByLabelText(/密码/);
const submitButton = screen.getByRole('button', { name: /登录/ });
fireEvent.change(usernameInput, { target: { value: 'admin' } });
fireEvent.change(passwordInput, { target: { value: 'password' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(login).toHaveBeenCalledWith('admin', 'password');
});
});
it('应该显示登录失败错误', async () => {
const { login } = useAuth();
login.mockRejectedValue(new Error('用户名或密码错误'));
renderLogin();
const usernameInput = screen.getByLabelText(/用户名/);
const passwordInput = screen.getByLabelText(/密码/);
const submitButton = screen.getByRole('button', { name: /登录/ });
fireEvent.change(usernameInput, { target: { value: 'admin' } });
fireEvent.change(passwordInput, { target: { value: 'wrong' } });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/用户名或密码错误/)).toBeInTheDocument();
});
});
});
```
### 5.2 测试Dashboard页面
```javascript
// src/pages/Dashboard/Dashboard.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Dashboard from './Dashboard';
// 模拟API
vi.mock('../../services/project', () => ({
projectAPI: {
getList: vi.fn(),
},
}));
vi.mock('../../services/statistics', () => ({
statisticsAPI: {
getBasicStatistics: vi.fn(),
},
}));
describe('Dashboard Page', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('应该渲染统计卡片', async () => {
const mockStatistics = {
total_count: 100,
total_contract_amount: 10000,
};
const { statisticsAPI } = await import('../../services/statistics');
statisticsAPI.getBasicStatistics.mockResolvedValue(mockStatistics);
render(
<BrowserRouter>
<Dashboard />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText(/项目总数/)).toBeInTheDocument();
expect(screen.getByText(/100/)).toBeInTheDocument();
});
});
it('应该渲染图表', async () => {
const mockStatistics = {
total_count: 100,
};
const { statisticsAPI } = await import('../../services/statistics');
statisticsAPI.getBasicStatistics.mockResolvedValue(mockStatistics);
render(
<BrowserRouter>
<Dashboard />
</BrowserRouter>
);
await waitFor(() => {
expect(screen.getByText(/项目趋势/)).toBeInTheDocument();
});
});
});
```
## 6. E2E测试
### 6.1 登录流程测试
```javascript
// tests/e2e/login.spec.js
import { test, expect } from '@playwright/test';
test.describe('登录流程', () => {
test('应该成功登录并跳转到仪表盘', async ({ page }) => {
// 访问登录页
await page.goto('http://localhost:3000/login');
// 输入用户名和密码
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'password123');
// 点击登录按钮
await page.click('button[type="submit"]');
// 验证跳转到仪表盘
await expect(page).toHaveURL('http://localhost:3000/dashboard');
// 验证用户信息显示
await expect(page.locator('.user-info')).toContainText('系统管理员');
});
test('应该显示登录失败错误', async ({ page }) => {
await page.goto('http://localhost:3000/login');
// 输入错误密码
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
// 验证错误提示
await expect(page.locator('.error-message')).toContainText('用户名或密码错误');
});
test('应该验证必填字段', async ({ page }) => {
await page.goto('http://localhost:3000/login');
// 直接点击登录按钮
await page.click('button[type="submit"]');
// 验证错误提示
await expect(page.locator('text=请输入用户名')).toBeVisible();
await expect(page.locator('text=请输入密码')).toBeVisible();
});
});
```
### 6.2 项目管理测试
```javascript
// tests/e2e/projects.spec.js
import { test, expect } from '@playwright/test';
test.describe('项目管理', () => {
test.beforeEach(async ({ page }) => {
// 登录
await page.goto('http://localhost:3000/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
});
test('应该显示项目列表', async ({ page }) => {
// 导航到项目列表
await page.click('text=项目管理');
await page.waitForURL('**/projects');
// 验证表格显示
await expect(page.locator('table')).toBeVisible();
await expect(page.locator('th:has-text("项目编号")')).toBeVisible();
await expect(page.locator('th:has-text("项目名称")')).toBeVisible();
});
test('应该创建新项目', async ({ page }) => {
await page.click('text=项目管理');
await page.waitForURL('**/projects');
// 点击新建项目按钮
await page.click('button:has-text("新建项目")');
// 填写表单
await page.fill('input[name="project_no"]', 'PRJ001');
await page.fill('input[name="name"]', '测试项目');
await page.selectOption('select[name="engineering_type"]', '基建');
await page.fill('input[name="contract_amount"]', '100');
// 提交表单
await page.click('button:has-text("保存")');
// 验证成功提示
await expect(page.locator('.success-message')).toContainText('项目创建成功');
// 验证项目出现在列表中
await expect(page.locator('table')).toContainText('PRJ001');
});
test('应该搜索项目', async ({ page }) => {
await page.click('text=项目管理');
await page.waitForURL('**/projects');
// 输入搜索关键词
await page.fill('input[placeholder*="搜索"]', '测试项目');
await page.press('input[placeholder*="搜索"]', 'Enter');
// 验证搜索结果
await expect(page.locator('table')).toContainText('测试项目');
});
test('应该筛选项目', async ({ page }) => {
await page.click('text=项目管理');
await page.waitForURL('**/projects');
// 选择工程类别
await page.click('select[name="engineering_type"]');
await page.click('option:has-text("基建")');
// 验证筛选结果
await expect(page.locator('table')).toBeVisible();
});
test('应该删除项目', async ({ page }) => {
await page.click('text=项目管理');
await page.waitForURL('**/projects');
// 点击删除按钮
await page.click('button:has-text("删除")');
// 确认删除
await page.click('button:has-text("确定")');
// 验证成功提示
await expect(page.locator('.success-message')).toContainText('项目删除成功');
});
});
```
### 6.3 用户管理测试
```javascript
// tests/e2e/users.spec.js
import { test, expect } from '@playwright/test';
test.describe('用户管理', () => {
test.beforeEach(async ({ page }) => {
// 管理员登录
await page.goto('http://localhost:3000/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
});
test('应该显示用户列表', async ({ page }) => {
await page.click('text=用户管理');
await page.waitForURL('**/users');
await expect(page.locator('table')).toBeVisible();
await expect(page.locator('th:has-text("用户名")')).toBeVisible();
});
test('应该创建新用户', async ({ page }) => {
await page.click('text=用户管理');
await page.waitForURL('**/users');
await page.click('button:has-text("新建用户")');
// 填写用户信息
await page.fill('input[name="username"]', 'testuser');
await page.fill('input[name="password"]', 'password123');
await page.fill('input[name="real_name"]', '测试用户');
await page.selectOption('select[name="department"]', '管理部');
await page.selectOption('select[name="role"]', 'admin');
await page.click('button:has-text("保存")');
await expect(page.locator('.success-message')).toContainText('用户创建成功');
});
test('应该重置用户密码', async ({ page }) => {
await page.click('text=用户管理');
await page.waitForURL('**/users');
await page.click('button:has-text("重置密码")');
await page.fill('input[name="new_password"]', 'newpassword123');
await page.click('button:has-text("确定")');
await expect(page.locator('.success-message')).toContainText('密码重置成功');
});
});
```
## 7. 测试最佳实践
### 7.1 单元测试最佳实践
1. **测试名称应该描述清楚**
```javascript
// 好的测试名称
it('应该正确格式化金额为千分位分隔', () => {})
// 不好的测试名称
it('测试formatMoney函数', () => {})
```
2. **遵循AAA模式**
```javascript
it('应该正确格式化金额', () => {
// Arrange (准备)
const amount = 1234.56;
// Act (执行)
const result = formatMoney(amount);
// Assert (断言)
expect(result).toBe('1,234.56');
});
```
3. **测试边界情况**
```javascript
it('应该处理边界值', () => {
expect(formatMoney(0)).toBe('0.00');
expect(formatMoney(0.005)).toBe('0.01');
expect(formatMoney(9999999999)).toBe('9,999,999,999.00');
});
```
4. **避免测试实现细节**
```javascript
// 不好的测试:测试内部状态
it('应该设置loading状态为true', () => {
render(<Login />);
fireEvent.click(screen.getByText('登录'));
expect(component.state.loading).toBe(true);
});
// 好的测试:测试用户可见行为
it('应该显示加载状态', () => {
render(<Login />);
fireEvent.click(screen.getByText('登录'));
expect(screen.getByRole('button')).toBeDisabled();
});
```
### 7.2 集成测试最佳实践
1. **关注用户行为**
```javascript
// 好的测试:模拟用户交互
render(<ProjectForm />);
fireEvent.change(screen.getByLabelText('项目名称'), {
target: { value: '新项目' },
});
fireEvent.click(screen.getByText('保存'));
// 不好的测试:直接设置props
render(<ProjectForm value="新项目" />);
```
2. **使用合适的查询方法**
```javascript
// 优先级从高到低
screen.getByRole('button', { name: '保存' });
screen.getByLabelText('项目名称');
screen.getByPlaceholderText('请输入项目名称');
screen.getByText('保存');
screen.getByTestId('submit-button'); // 最后选择
```
3. **等待异步操作完成**
```javascript
it('应该提交表单', async () => {
render(<ProjectForm />);
fireEvent.click(screen.getByText('保存'));
await waitFor(() => {
expect(screen.getByText('保存成功')).toBeInTheDocument();
});
});
```
### 7.3 E2E测试最佳实践
1. **使用Page Object模式**
```javascript
// pages/ProjectListPage.js
class ProjectListPage {
constructor(page) {
this.page = page;
}
async goto() {
await this.page.goto('/projects');
}
async createProject(data) {
await this.page.click('button:has-text("新建项目")');
await this.page.fill('input[name="name"]', data.name);
await this.page.click('button:has-text("保存")');
}
async getProjectRows() {
return this.page.locator('table tbody tr');
}
}
// 测试中使用
test('应该创建项目', async ({ page }) => {
const projectPage = new ProjectListPage(page);
await projectPage.goto();
await projectPage.createProject({ name: '测试项目' });
const rows = await projectPage.getProjectRows();
await expect(rows).toHaveCount(1);
});
```
2. **避免硬编码选择器**
```javascript
// 好的
await page.click('button:has-text("保存")');
await page.click('[data-testid="save-button"]');
// 不好的
await page.click('.btn-primary.btn-lg');
await page.click('#submit-btn-12345');
```
3. **添加合理的等待**
```javascript
// 好的:使用自动等待
await page.waitForSelector('.success-message');
// 不好:固定延迟
await page.waitForTimeout(1000);
```
## 8. 测试命令
```bash
# 运行所有单元测试
npm run test
# 运行测试并监听文件变化
npm run test:watch
# 运行测试并生成覆盖率报告
npm run test:coverage
# 运行E2E测试
npm run test:e2e
# 运行E2E测试并打开UI
npm run test:e2e:ui
# 运行E2E测试(特定浏览器)
npx playwright test --project=chromium
```
## 9. CI/CD集成
### 9.1 GitHub Actions配置
```yaml
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
```
## 10. 调试测试
### 10.1 调试单元测试
```javascript
// 使用debug()
test('调试测试', () => {
debug();
// 这会暂停测试,让你在浏览器中检查DOM
});
```
### 10.2 调试E2E测试
```javascript
// 使用Playwright Inspector
test('调试E2E测试', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.pause(); // 暂停执行,打开Inspector
await page.click('button');
});
```
---
**文档维护**: 前端程序员
**文档类型**: 前端测试技术文档
**最后更新**: 2026-01-25