# 前端测试技术文档 ## 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( ); }; 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( ); 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( ); 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(); fireEvent.click(screen.getByText('登录')); expect(component.state.loading).toBe(true); }); // 好的测试:测试用户可见行为 it('应该显示加载状态', () => { render(); fireEvent.click(screen.getByText('登录')); expect(screen.getByRole('button')).toBeDisabled(); }); ``` ### 7.2 集成测试最佳实践 1. **关注用户行为** ```javascript // 好的测试:模拟用户交互 render(); fireEvent.change(screen.getByLabelText('项目名称'), { target: { value: '新项目' }, }); fireEvent.click(screen.getByText('保存')); // 不好的测试:直接设置props render(); ``` 2. **使用合适的查询方法** ```javascript // 优先级从高到低 screen.getByRole('button', { name: '保存' }); screen.getByLabelText('项目名称'); screen.getByPlaceholderText('请输入项目名称'); screen.getByText('保存'); screen.getByTestId('submit-button'); // 最后选择 ``` 3. **等待异步操作完成** ```javascript it('应该提交表单', async () => { render(); 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