前端测试介绍
前端测试是确保Web应用程序质量、可靠性和用户体验的关键环节。通过系统化的测试策略,可以及早发现和修复问题,提高代码质量和开发效率。
测试的重要性
质量保证
- 功能验证:确保应用按预期工作
- 回归测试:防止新功能破坏现有功能
- 用户体验:保证界面和交互的一致性
开发效率
- 快速反馈:自动化测试提供即时反馈
- 重构安全:测试为重构提供安全保障
- 文档作用:测试代码作为活文档
团队协作
- 代码审查:测试帮助理解代码意图
- 持续集成:自动化测试支持CI/CD流程
- 知识传承:测试用例记录业务逻辑
测试金字塔
单元测试(底层)
- 范围:单个函数、组件或模块
- 速度:最快,毫秒级
- 成本:最低
- 工具:Jest、Mocha、Vitest等
集成测试(中层)
- 范围:多个组件或模块的交互
- 速度:中等,秒级
- 成本:中等
- 工具:Testing Library、Cypress等
端到端测试(顶层)
- 范围:完整用户流程
- 速度:最慢,分钟级
- 成本:最高
- 工具:Cypress、Playwright、Selenium等
测试类型
功能测试
- 单元测试:测试单个函数或组件
- 集成测试:测试组件间交互
- 端到端测试:测试完整用户流程
非功能测试
- 性能测试:测试应用性能表现
- 可访问性测试:测试无障碍访问
- 兼容性测试:测试跨浏览器兼容性
- 安全测试:测试安全漏洞
测试工具生态
测试框架
- Jest:Facebook开发的测试框架
- Vitest:基于Vite的现代测试框架
- Mocha:灵活的JavaScript测试框架
- Jasmine:行为驱动开发测试框架
测试库
- React Testing Library:React组件测试
- Vue Test Utils:Vue组件测试
- Testing Library:通用DOM测试库
- Enzyme:React组件测试(已不推荐)
端到端测试
- Cypress:现代Web应用测试
- Playwright:微软开发的测试工具
- Selenium:传统WebDriver测试
- Puppeteer:Chrome DevTools协议
视觉测试
- Percy:视觉回归测试
- BackstopJS:视觉回归测试
- Chromatic:Storybook视觉测试
测试最佳实践
测试编写原则
- AAA模式:Arrange(准备)、Act(执行)、Assert(断言)
- FIRST原则:Fast、Independent、Repeatable、Self-validating、Timely
- 单一职责:每个测试只测试一个功能点
- 可读性:测试代码应该清晰易懂
测试覆盖率
- 行覆盖率:代码执行行数比例
- 分支覆盖率:条件分支执行比例
- 函数覆盖率:被调用函数的比例
- 语句覆盖率:被执行语句的比例
测试策略
- 测试驱动开发(TDD):先写测试,再写实现
- 行为驱动开发(BDD):基于用户行为编写测试
- 持续测试:在CI/CD流程中集成自动化测试
测试实践示例
单元测试示例
javascript
1// utils.js2export function add(a, b) {3 return a + b;4}56export function formatCurrency(amount) {7 return new Intl.NumberFormat('zh-CN', {8 style: 'currency',9 currency: 'CNY'10 }).format(amount);11}1213// utils.test.js14import { add, formatCurrency } from './utils';1516describe('工具函数测试', () => {17 describe('add函数', () => {18 test('应该正确计算两个正数的和', () => {19 expect(add(2, 3)).toBe(5);20 });2122 test('应该正确处理负数', () => {23 expect(add(-1, 1)).toBe(0);24 });2526 test('应该正确处理小数', () => {27 expect(add(0.1, 0.2)).toBeCloseTo(0.3);28 });29 });3031 describe('formatCurrency函数', () => {32 test('应该正确格式化货币', () => {33 expect(formatCurrency(1234.56)).toBe('¥1,234.56');34 });3536 test('应该处理零值', () => {37 expect(formatCurrency(0)).toBe('¥0.00');38 });39 });40});React组件测试示例
javascript
1// Button.jsx2import React from 'react';34export function Button({ children, onClick, disabled = false, variant = 'primary' }) {5 return (6 <button7 className={`btn btn--${variant}`}8 onClick={onClick}9 disabled={disabled}10 >11 {children}12 </button>13 );14}1516// Button.test.jsx17import React from 'react';18import { render, screen, fireEvent } from '@testing-library/react';19import { Button } from './Button';2021describe('Button组件', () => {22 test('应该渲染按钮文本', () => {23 render(<Button>点击我</Button>);24 expect(screen.getByText('点击我')).toBeInTheDocument();25 });2627 test('应该响应点击事件', () => {28 const handleClick = jest.fn();29 render(<Button onClick={handleClick}>点击我</Button>);30 31 fireEvent.click(screen.getByText('点击我'));32 expect(handleClick).toHaveBeenCalledTimes(1);33 });3435 test('禁用状态下不应响应点击', () => {36 const handleClick = jest.fn();37 render(<Button onClick={handleClick} disabled>点击我</Button>);38 39 fireEvent.click(screen.getByText('点击我'));40 expect(handleClick).not.toHaveBeenCalled();41 });4243 test('应该应用正确的CSS类', () => {44 render(<Button variant="secondary">按钮</Button>);45 expect(screen.getByText('按钮')).toHaveClass('btn--secondary');46 });47});集成测试示例
javascript
1// TodoApp.test.jsx2import React from 'react';3import { render, screen, fireEvent, waitFor } from '@testing-library/react';4import userEvent from '@testing-library/user-event';5import { TodoApp } from './TodoApp';67describe('TodoApp集成测试', () => {8 test('完整的添加和删除待办事项流程', async () => {9 const user = userEvent.setup();10 render(<TodoApp />);1112 // 添加新的待办事项13 const input = screen.getByPlaceholderText('输入待办事项...');14 const addButton = screen.getByText('添加');1516 await user.type(input, '学习React测试');17 await user.click(addButton);1819 // 验证待办事项已添加20 expect(screen.getByText('学习React测试')).toBeInTheDocument();21 expect(input).toHaveValue('');2223 // 标记为完成24 const checkbox = screen.getByRole('checkbox');25 await user.click(checkbox);2627 // 验证状态变化28 expect(checkbox).toBeChecked();29 expect(screen.getByText('学习React测试')).toHaveClass('completed');3031 // 删除待办事项32 const deleteButton = screen.getByText('删除');33 await user.click(deleteButton);3435 // 验证待办事项已删除36 expect(screen.queryByText('学习React测试')).not.toBeInTheDocument();37 });38});测试配置
Jest配置示例
javascript
1// jest.config.js2module.exports = {3 testEnvironment: 'jsdom',4 setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],5 moduleNameMapping: {6 '\\.(css|less|scss|sass)$': 'identity-obj-proxy',7 '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$': 'jest-transform-stub'8 },9 collectCoverageFrom: [10 'src/**/*.{js,jsx}',11 '!src/index.js',12 '!src/reportWebVitals.js'13 ],14 coverageThreshold: {15 global: {16 branches: 80,17 functions: 80,18 lines: 80,19 statements: 8020 }21 }22};测试环境设置
javascript
1// src/setupTests.js2import '@testing-library/jest-dom';34// Mock全局对象5global.fetch = jest.fn();67// Mock localStorage8const localStorageMock = {9 getItem: jest.fn(),10 setItem: jest.fn(),11 removeItem: jest.fn(),12 clear: jest.fn(),13};14global.localStorage = localStorageMock;1516// 清理函数17afterEach(() => {18 jest.clearAllMocks();19});测试策略建议
测试优先级
- 核心业务逻辑:优先测试关键功能
- 用户交互:测试主要用户流程
- 边界条件:测试异常情况和边界值
- 性能关键路径:测试性能敏感的代码
测试维护
- 定期重构测试代码:保持测试代码的质量
- 更新测试用例:随着功能变化更新测试
- 删除过时测试:移除不再相关的测试用例
- 测试文档化:为复杂测试添加说明
通过系统化的测试实践,可以显著提高前端应用的质量和可维护性,为用户提供更稳定可靠的产品体验。
- 覆盖率指标
- 测试数据管理
- Mock策略
测试覆盖率详解
覆盖率指标说明
| 覆盖率类型 | 计算方式 | 目标值 | 重要性 | 注意事项 |
|---|---|---|---|---|
| 行覆盖率 | 执行行数/总行数 | >80% | 基础指标 | 可能遗漏逻辑分支 |
| 分支覆盖率 | 执行分支/总分支 | >85% | 重要指标 | 确保所有条件都被测试 |
| 函数覆盖率 | 调用函数/总函数 | >90% | 基础指标 | 不代表函数内部逻辑完整性 |
| 语句覆盖率 | 执行语句/总语句 | >85% | 详细指标 | 最细粒度的覆盖率统计 |
覆盖率监控实现
javascript
1// 覆盖率配置示例2module.exports = {3 collectCoverageFrom: [4 'src/**/*.{js,jsx,ts,tsx}',5 '!src/**/*.d.ts',6 '!src/**/*.stories.{js,jsx,ts,tsx}',7 '!src/index.tsx',8 '!src/reportWebVitals.ts'9 ],10 11 coverageThreshold: {12 global: {13 branches: 80,14 functions: 80,15 lines: 80,16 statements: 8017 },18 // 核心模块要求更高覆盖率19 './src/utils/': {20 branches: 95,21 functions: 95,22 lines: 95,23 statements: 9524 },25 // 组件覆盖率要求26 './src/components/': {27 branches: 85,28 functions: 90,29 lines: 85,30 statements: 8531 }32 },33 34 coverageReporters: [35 'text', // 控制台输出36 'text-summary', // 简要摘要37 'html', // HTML报告38 'lcov', // LCOV格式39 'json-summary' // JSON摘要40 ]41};4243// 自定义覆盖率报告44class CoverageReporter {45 constructor(options = {}) {46 this.threshold = options.threshold || 80;47 this.outputPath = options.outputPath || './coverage';48 }49 50 generateReport(coverageData) {51 const report = {52 timestamp: new Date().toISOString(),53 summary: this.calculateSummary(coverageData),54 details: this.analyzeDetails(coverageData),55 recommendations: this.generateRecommendations(coverageData)56 };57 58 return report;59 }60 61 calculateSummary(data) {62 return {63 lines: {64 total: data.numTotalStatements,65 covered: data.numCoveredStatements,66 percentage: (data.numCoveredStatements / data.numTotalStatements * 100).toFixed(2)67 },68 branches: {69 total: data.numTotalBranches,70 covered: data.numCoveredBranches,71 percentage: (data.numCoveredBranches / data.numTotalBranches * 100).toFixed(2)72 },73 functions: {74 total: data.numTotalFunctions,75 covered: data.numCoveredFunctions,76 percentage: (data.numCoveredFunctions / data.numTotalFunctions * 100).toFixed(2)77 }78 };79 }80 81 analyzeDetails(data) {82 const uncoveredFiles = [];83 const lowCoverageFiles = [];84 85 Object.entries(data.coverageMap).forEach(([filePath, fileData]) => {86 const coverage = this.calculateFileCoverage(fileData);87 88 if (coverage.lines.percentage === 0) {89 uncoveredFiles.push(filePath);90 } else if (coverage.lines.percentage < this.threshold) {91 lowCoverageFiles.push({92 file: filePath,93 coverage: coverage.lines.percentage94 });95 }96 });97 98 return {99 uncoveredFiles,100 lowCoverageFiles: lowCoverageFiles.sort((a, b) => a.coverage - b.coverage)101 };102 }103 104 generateRecommendations(data) {105 const recommendations = [];106 const summary = this.calculateSummary(data);107 108 if (summary.branches.percentage < 80) {109 recommendations.push({110 type: 'branch_coverage',111 message: '分支覆盖率偏低,建议增加条件分支测试',112 priority: 'high'113 });114 }115 116 if (summary.functions.percentage < 90) {117 recommendations.push({118 type: 'function_coverage',119 message: '函数覆盖率偏低,建议为未测试函数添加测试用例',120 priority: 'medium'121 });122 }123 124 return recommendations;125 }126}测试数据管理策略
测试数据工厂模式
javascript
1// 测试数据工厂2class TestDataFactory {3 static createUser(overrides = {}) {4 return {5 id: Math.floor(Math.random() * 1000),6 name: 'Test User',7 email: 'test@example.com',8 age: 25,9 role: 'user',10 createdAt: new Date().toISOString(),11 ...overrides12 };13 }14 15 static createUserList(count = 3, overrides = {}) {16 return Array.from({ length: count }, (_, index) => 17 this.createUser({18 id: index + 1,19 name: `User ${index + 1}`,20 email: `user${index + 1}@example.com`,21 ...overrides22 })23 );24 }25 26 static createProduct(overrides = {}) {27 return {28 id: Math.floor(Math.random() * 1000),29 name: 'Test Product',30 price: 99.99,31 category: 'electronics',32 inStock: true,33 description: 'A test product for testing purposes',34 ...overrides35 };36 }37 38 static createOrder(overrides = {}) {39 return {40 id: Math.floor(Math.random() * 1000),41 userId: 1,42 items: [this.createProduct()],43 total: 99.99,44 status: 'pending',45 createdAt: new Date().toISOString(),46 ...overrides47 };48 }49}5051// 测试数据构建器模式52class UserBuilder {53 constructor() {54 this.user = {55 id: 1,56 name: 'Test User',57 email: 'test@example.com',58 age: 25,59 role: 'user'60 };61 }62 63 withId(id) {64 this.user.id = id;65 return this;66 }67 68 withName(name) {69 this.user.name = name;70 return this;71 }72 73 withEmail(email) {74 this.user.email = email;75 return this;76 }77 78 withAge(age) {79 this.user.age = age;80 return this;81 }82 83 withRole(role) {84 this.user.role = role;85 return this;86 }87 88 asAdmin() {89 this.user.role = 'admin';90 return this;91 }92 93 asGuest() {94 this.user.role = 'guest';95 return this;96 }97 98 build() {99 return { ...this.user };100 }101}102103// 使用示例104describe('User Service', () => {105 it('should handle admin users', () => {106 const adminUser = new UserBuilder()107 .withName('Admin User')108 .withEmail('admin@example.com')109 .asAdmin()110 .build();111 112 expect(userService.canDeleteUser(adminUser)).toBe(true);113 });114 115 it('should handle multiple users', () => {116 const users = TestDataFactory.createUserList(5);117 expect(users).toHaveLength(5);118 expect(users[0].name).toBe('User 1');119 });120});121122// 测试数据清理123class TestDataCleaner {124 constructor() {125 this.createdData = [];126 }127 128 track(data) {129 this.createdData.push(data);130 return data;131 }132 133 cleanup() {134 // 清理所有创建的测试数据135 this.createdData.forEach(data => {136 if (data.cleanup && typeof data.cleanup === 'function') {137 data.cleanup();138 }139 });140 this.createdData = [];141 }142}143144// 全局测试数据管理145const testDataCleaner = new TestDataCleaner();146147beforeEach(() => {148 // 每个测试前清理数据149 testDataCleaner.cleanup();150});151152afterAll(() => {153 // 所有测试完成后清理154 testDataCleaner.cleanup();155});Mock和Stub策略
Mock策略实现
javascript
1// API Mock策略2class APIMocker {3 constructor() {4 this.mocks = new Map();5 this.originalFetch = global.fetch;6 }7 8 mockEndpoint(url, response, options = {}) {9 const { method = 'GET', status = 200, delay = 0 } = options;10 11 this.mocks.set(`${method}:${url}`, {12 response,13 status,14 delay15 });16 }17 18 install() {19 global.fetch = jest.fn().mockImplementation(async (url, options = {}) => {20 const method = options.method || 'GET';21 const key = `${method}:${url}`;22 23 if (this.mocks.has(key)) {24 const mock = this.mocks.get(key);25 26 // 模拟网络延迟27 if (mock.delay > 0) {28 await new Promise(resolve => setTimeout(resolve, mock.delay));29 }30 31 return Promise.resolve({32 ok: mock.status >= 200 && mock.status < 300,33 status: mock.status,34 json: async () => mock.response,35 text: async () => JSON.stringify(mock.response)36 });37 }38 39 // 如果没有mock,返回40440 return Promise.resolve({41 ok: false,42 status: 404,43 json: async () => ({ error: 'Not found' })44 });45 });46 }47 48 uninstall() {49 global.fetch = this.originalFetch;50 this.mocks.clear();51 }52 53 reset() {54 this.mocks.clear();55 }56}5758// 使用示例59describe('User API', () => {60 const apiMocker = new APIMocker();61 62 beforeAll(() => {63 apiMocker.install();64 });65 66 afterAll(() => {67 apiMocker.uninstall();68 });69 70 beforeEach(() => {71 apiMocker.reset();72 });73 74 it('should fetch user successfully', async () => {75 const mockUser = { id: 1, name: 'John Doe' };76 apiMocker.mockEndpoint('/api/users/1', mockUser);77 78 const user = await fetchUser(1);79 expect(user).toEqual(mockUser);80 });81 82 it('should handle API errors', async () => {83 apiMocker.mockEndpoint('/api/users/999', 84 { error: 'User not found' }, 85 { status: 404 }86 );87 88 await expect(fetchUser(999)).rejects.toThrow('User not found');89 });90 91 it('should handle network delays', async () => {92 const mockUser = { id: 1, name: 'John Doe' };93 apiMocker.mockEndpoint('/api/users/1', mockUser, { delay: 1000 });94 95 const startTime = Date.now();96 await fetchUser(1);97 const endTime = Date.now();98 99 expect(endTime - startTime).toBeGreaterThanOrEqual(1000);100 });101});102103// 模块Mock工厂104class ModuleMocker {105 static createMockModule(modulePath, mockImplementation = {}) {106 return jest.doMock(modulePath, () => ({107 __esModule: true,108 ...mockImplementation109 }));110 }111 112 static createPartialMock(modulePath, mockedMethods = {}) {113 return jest.doMock(modulePath, () => ({114 ...jest.requireActual(modulePath),115 ...mockedMethods116 }));117 }118 119 static createSpyModule(modulePath, spiedMethods = []) {120 const actualModule = jest.requireActual(modulePath);121 const spies = {};122 123 spiedMethods.forEach(method => {124 spies[method] = jest.spyOn(actualModule, method);125 });126 127 return spies;128 }129}130131// 时间Mock132class TimeMocker {133 constructor() {134 this.originalDate = Date;135 this.originalSetTimeout = setTimeout;136 this.originalSetInterval = setInterval;137 }138 139 mockDate(fixedDate) {140 const MockDate = class extends Date {141 constructor(...args) {142 if (args.length === 0) {143 super(fixedDate);144 } else {145 super(...args);146 }147 }148 149 static now() {150 return new Date(fixedDate).getTime();151 }152 };153 154 global.Date = MockDate;155 }156 157 mockTimers() {158 jest.useFakeTimers();159 }160 161 restoreTimers() {162 jest.useRealTimers();163 }164 165 restoreDate() {166 global.Date = this.originalDate;167 }168 169 advanceTime(ms) {170 jest.advanceTimersByTime(ms);171 }172 173 runAllTimers() {174 jest.runAllTimers();175 }176}177178// 使用示例179describe('Time-dependent functionality', () => {180 const timeMocker = new TimeMocker();181 182 beforeEach(() => {183 timeMocker.mockTimers();184 timeMocker.mockDate('2023-01-01T00:00:00.000Z');185 });186 187 afterEach(() => {188 timeMocker.restoreTimers();189 timeMocker.restoreDate();190 });191 192 it('should handle scheduled tasks', () => {193 const callback = jest.fn();194 195 setTimeout(callback, 1000);196 expect(callback).not.toHaveBeenCalled();197 198 timeMocker.advanceTime(1000);199 expect(callback).toHaveBeenCalledTimes(1);200 });201 202 it('should work with fixed dates', () => {203 const now = new Date();204 expect(now.toISOString()).toBe('2023-01-01T00:00:00.000Z');205 });206});持续集成中的测试
CI/CD集成最佳实践
- GitHub Actions
- Jenkins
- 测试策略
GitHub Actions测试配置
.github/workflows/test.yml
yaml
1name: Test Suite23on:4 push:5 branches: [ main, develop ]6 pull_request:7 branches: [ main ]89jobs:10 test:11 runs-on: ubuntu-latest12 13 strategy:14 matrix:15 node-version: [16.x, 18.x, 20.x]16 17 steps:18 - name: Checkout code19 uses: actions/checkout@v420 21 - name: Setup Node.js ${{ matrix.node-version }}22 uses: actions/setup-node@v423 with:24 node-version: ${{ matrix.node-version }}25 cache: 'npm'26 27 - name: Install dependencies28 run: npm ci29 30 - name: Run linting31 run: npm run lint32 33 - name: Run type checking34 run: npm run type-check35 36 - name: Run unit tests37 run: npm run test:coverage38 39 - name: Run integration tests40 run: npm run test:integration41 42 - name: Upload coverage reports43 uses: codecov/codecov-action@v344 with:45 file: ./coverage/lcov.info46 flags: unittests47 name: codecov-umbrella48 49 - name: Comment PR with coverage50 if: github.event_name == 'pull_request'51 uses: romeovs/lcov-reporter-action@v0.3.152 with:53 github-token: ${{ secrets.GITHUB_TOKEN }}54 lcov-file: ./coverage/lcov.info55 56 - name: Archive test results57 if: always()58 uses: actions/upload-artifact@v359 with:60 name: test-results-${{ matrix.node-version }}61 path: |62 coverage/63 test-results.xml64 65 e2e:66 runs-on: ubuntu-latest67 needs: test68 69 steps:70 - name: Checkout code71 uses: actions/checkout@v472 73 - name: Setup Node.js74 uses: actions/setup-node@v475 with:76 node-version: '18.x'77 cache: 'npm'78 79 - name: Install dependencies80 run: npm ci81 82 - name: Build application83 run: npm run build84 85 - name: Start application86 run: npm start &87 88 - name: Wait for application89 run: npx wait-on http://localhost:300090 91 - name: Run E2E tests92 run: npm run test:e2e93 94 - name: Upload E2E artifacts95 if: failure()96 uses: actions/upload-artifact@v397 with:98 name: e2e-artifacts99 path: |100 cypress/screenshots/101 cypress/videos/Jenkins测试流水线
Jenkinsfile
groovy
1pipeline {2 agent any3 4 environment {5 NODE_VERSION = '18'6 CI = 'true'7 }8 9 stages {10 stage('Checkout') {11 steps {12 checkout scm13 }14 }15 16 stage('Setup') {17 steps {18 script {19 // 安装Node.js20 sh "nvm use ${NODE_VERSION}"21 sh 'npm ci'22 }23 }24 }25 26 stage('Code Quality') {27 parallel {28 stage('Lint') {29 steps {30 sh 'npm run lint'31 }32 post {33 always {34 publishHTML([35 allowMissing: false,36 alwaysLinkToLastBuild: true,37 keepAll: true,38 reportDir: 'lint-results',39 reportFiles: 'index.html',40 reportName: 'ESLint Report'41 ])42 }43 }44 }45 46 stage('Type Check') {47 steps {48 sh 'npm run type-check'49 }50 }51 }52 }53 54 stage('Test') {55 parallel {56 stage('Unit Tests') {57 steps {58 sh 'npm run test:coverage'59 }60 post {61 always {62 // 发布测试结果63 publishTestResults testResultsPattern: 'test-results.xml'64 65 // 发布覆盖率报告66 publishHTML([67 allowMissing: false,68 alwaysLinkToLastBuild: true,69 keepAll: true,70 reportDir: 'coverage/lcov-report',71 reportFiles: 'index.html',72 reportName: 'Coverage Report'73 ])74 75 // 覆盖率检查76 script {77 def coverage = readJSON file: 'coverage/coverage-summary.json'78 def linesCoverage = coverage.total.lines.pct79 80 if (linesCoverage < 80) {81 error "Coverage ${linesCoverage}% is below threshold 80%"82 }83 }84 }85 }86 }87 88 stage('Integration Tests') {89 steps {90 sh 'npm run test:integration'91 }92 }93 }94 }95 96 stage('E2E Tests') {97 when {98 anyOf {99 branch 'main'100 branch 'develop'101 }102 }103 steps {104 sh 'npm run build'105 sh 'npm start &'106 sh 'npx wait-on http://localhost:3000'107 sh 'npm run test:e2e'108 }109 post {110 always {111 archiveArtifacts artifacts: 'cypress/screenshots/**,cypress/videos/**', allowEmptyArchive: true112 }113 }114 }115 }116 117 post {118 always {119 // 清理工作空间120 cleanWs()121 }122 123 failure {124 // 发送失败通知125 emailext (126 subject: "Build Failed: ${env.JOB_NAME} - ${env.BUILD_NUMBER}",127 body: "Build failed. Check console output at ${env.BUILD_URL}",128 to: "${env.CHANGE_AUTHOR_EMAIL}"129 )130 }131 132 success {133 // 发送成功通知134 script {135 if (env.BRANCH_NAME == 'main') {136 slackSend(137 channel: '#deployments',138 color: 'good',139 message: "✅ Tests passed for ${env.JOB_NAME} - ${env.BUILD_NUMBER}"140 )141 }142 }143 }144 }145}测试策略配置
测试策略配置
javascript
1// 测试环境配置2const testConfig = {3 // 快速反馈策略4 fastFeedback: {5 // 优先运行的测试6 priority: [7 'src/**/*.test.js',8 'src/utils/**/*.test.js',9 'src/components/**/*.test.js'10 ],11 // 并行执行配置12 parallel: {13 workers: '50%',14 maxConcurrency: 415 },16 // 超时配置17 timeout: 500018 },19 20 // 完整测试策略21 comprehensive: {22 include: [23 'src/**/*.test.js',24 'src/**/*.spec.js',25 'integration/**/*.test.js',26 'e2e/**/*.test.js'27 ],28 timeout: 30000,29 retries: 230 },31 32 // 性能测试策略33 performance: {34 thresholds: {35 unitTest: 100, // 单元测试最大执行时间(ms)36 integration: 5000, // 集成测试最大执行时间(ms)37 e2e: 30000 // E2E测试最大执行时间(ms)38 },39 monitoring: {40 slowTests: true,41 memoryUsage: true,42 cpuUsage: true43 }44 }45};4647// 测试报告配置48const reportConfig = {49 reporters: [50 'default',51 ['jest-html-reporter', {52 pageTitle: 'Test Report',53 outputPath: './test-report.html',54 includeFailureMsg: true,55 includeSuiteFailure: true56 }],57 ['jest-junit', {58 outputDirectory: './test-results',59 outputName: 'junit.xml',60 suiteName: 'Jest Tests'61 }],62 ['jest-sonar-reporter', {63 outputDirectory: './test-results',64 outputName: 'sonar-report.xml'65 }]66 ],67 68 // 覆盖率报告69 coverageReporters: [70 'text',71 'text-summary',72 'html',73 'lcov',74 'cobertura'75 ],76 77 // 通知配置78 notifications: {79 slack: {80 webhook: process.env.SLACK_WEBHOOK,81 channel: '#testing',82 onFailure: true,83 onSuccess: false84 },85 email: {86 recipients: ['team@example.com'],87 onFailure: true,88 onCoverageThreshold: true89 }90 }91};9293// 测试数据管理94const testDataConfig = {95 // 测试数据库配置96 database: {97 host: process.env.TEST_DB_HOST || 'localhost',98 port: process.env.TEST_DB_PORT || 5432,99 database: 'test_db',100 resetBetweenTests: true,101 seedData: './test/fixtures/seed.sql'102 },103 104 // Mock服务配置105 mockServices: {106 api: {107 baseUrl: 'http://localhost:3001',108 endpoints: './test/mocks/api-mocks.json'109 },110 external: {111 payment: 'mock',112 email: 'mock',113 sms: 'mock'114 }115 },116 117 // 测试文件配置118 fixtures: {119 users: './test/fixtures/users.json',120 products: './test/fixtures/products.json',121 orders: './test/fixtures/orders.json'122 }123};124125module.exports = {126 testConfig,127 reportConfig,128 testDataConfig129};测试挑战与解决方案
常见测试挑战
- 异步测试
- 时间测试
- 外部依赖
异步测试解决方案
异步测试最佳实践
javascript
1// Promise测试2describe('Async Operations', () => {3 // 1. 使用async/await4 it('should handle async operations with async/await', async () => {5 const result = await fetchUserData(1);6 expect(result).toEqual({ id: 1, name: 'John' });7 });8 9 // 2. 使用Promise.resolves/rejects10 it('should handle promise resolution', () => {11 return expect(fetchUserData(1)).resolves.toEqual({ id: 1, name: 'John' });12 });13 14 it('should handle promise rejection', () => {15 return expect(fetchUserData(-1)).rejects.toThrow('Invalid user ID');16 });17 18 // 3. 使用waitFor处理异步DOM更新19 it('should wait for DOM updates', async () => {20 render(<AsyncComponent />);21 22 await waitFor(() => {23 expect(screen.getByText('Loaded')).toBeInTheDocument();24 });25 });26 27 // 4. 处理复杂异步流程28 it('should handle complex async flows', async () => {29 const mockApi = jest.fn()30 .mockResolvedValueOnce({ step: 1 })31 .mockResolvedValueOnce({ step: 2 })32 .mockResolvedValueOnce({ step: 3, complete: true });33 34 const result = await processMultiStepOperation(mockApi);35 36 expect(mockApi).toHaveBeenCalledTimes(3);37 expect(result.complete).toBe(true);38 });39});4041// 异步测试工具类42class AsyncTestHelper {43 static async waitForCondition(condition, timeout = 5000, interval = 100) {44 const startTime = Date.now();45 46 while (Date.now() - startTime < timeout) {47 if (await condition()) {48 return true;49 }50 await this.sleep(interval);51 }52 53 throw new Error(`Condition not met within ${timeout}ms`);54 }55 56 static sleep(ms) {57 return new Promise(resolve => setTimeout(resolve, ms));58 }59 60 static async waitForElement(selector, timeout = 5000) {61 return this.waitForCondition(62 () => document.querySelector(selector) !== null,63 timeout64 );65 }66 67 static async waitForApiCall(mockFn, expectedCalls = 1, timeout = 5000) {68 return this.waitForCondition(69 () => mockFn.mock.calls.length >= expectedCalls,70 timeout71 );72 }73}7475// 使用示例76describe('Complex Async Component', () => {77 it('should handle multi-step loading', async () => {78 const mockFetch = jest.fn()79 .mockResolvedValueOnce({ ok: true, json: () => ({ loading: true }) })80 .mockResolvedValueOnce({ ok: true, json: () => ({ data: 'result' }) });81 82 global.fetch = mockFetch;83 84 render(<ComplexAsyncComponent />);85 86 // 等待第一次API调用87 await AsyncTestHelper.waitForApiCall(mockFetch, 1);88 expect(screen.getByText('Loading...')).toBeInTheDocument();89 90 // 等待第二次API调用91 await AsyncTestHelper.waitForApiCall(mockFetch, 2);92 93 // 等待最终结果94 await waitFor(() => {95 expect(screen.getByText('result')).toBeInTheDocument();96 });97 });98});时间相关测试
时间测试解决方案
javascript
1// 时间Mock工具2class TimeTestHelper {3 constructor() {4 this.originalDate = Date;5 this.originalSetTimeout = global.setTimeout;6 this.originalSetInterval = global.setInterval;7 this.originalClearTimeout = global.clearTimeout;8 this.originalClearInterval = global.clearInterval;9 }10 11 // 固定时间12 freezeTime(timestamp) {13 const frozenTime = new Date(timestamp);14 15 global.Date = class extends Date {16 constructor(...args) {17 if (args.length === 0) {18 super(frozenTime);19 } else {20 super(...args);21 }22 }23 24 static now() {25 return frozenTime.getTime();26 }27 };28 }29 30 // 恢复时间31 restoreTime() {32 global.Date = this.originalDate;33 }34 35 // Mock定时器36 mockTimers() {37 jest.useFakeTimers();38 }39 40 // 恢复定时器41 restoreTimers() {42 jest.useRealTimers();43 }44 45 // 快进时间46 advanceTime(ms) {47 jest.advanceTimersByTime(ms);48 }49 50 // 执行所有定时器51 runAllTimers() {52 jest.runAllTimers();53 }54 55 // 执行下一个定时器56 runOnlyPendingTimers() {57 jest.runOnlyPendingTimers();58 }59}6061// 使用示例62describe('Time-dependent functionality', () => {63 const timeHelper = new TimeTestHelper();64 65 beforeEach(() => {66 timeHelper.mockTimers();67 timeHelper.freezeTime('2023-01-01T00:00:00.000Z');68 });69 70 afterEach(() => {71 timeHelper.restoreTimers();72 timeHelper.restoreTime();73 });74 75 it('should handle countdown timer', () => {76 const onComplete = jest.fn();77 const timer = new CountdownTimer(5000, onComplete);78 79 timer.start();80 81 // 快进4秒82 timeHelper.advanceTime(4000);83 expect(onComplete).not.toHaveBeenCalled();84 85 // 再快进1秒86 timeHelper.advanceTime(1000);87 expect(onComplete).toHaveBeenCalledTimes(1);88 });89 90 it('should handle date formatting', () => {91 const formatter = new DateFormatter();92 const result = formatter.formatNow();93 94 expect(result).toBe('2023-01-01 00:00:00');95 });96 97 it('should handle periodic tasks', () => {98 const callback = jest.fn();99 const interval = setInterval(callback, 1000);100 101 // 快进3秒102 timeHelper.advanceTime(3000);103 expect(callback).toHaveBeenCalledTimes(3);104 105 clearInterval(interval);106 });107});108109// 时区测试110describe('Timezone handling', () => {111 const originalTimezone = process.env.TZ;112 113 afterEach(() => {114 process.env.TZ = originalTimezone;115 });116 117 it('should handle different timezones', () => {118 // 设置时区119 process.env.TZ = 'America/New_York';120 121 const date = new Date('2023-01-01T12:00:00Z');122 const formatter = new DateFormatter();123 124 expect(formatter.formatLocal(date)).toBe('2023-01-01 07:00:00 EST');125 });126 127 it('should handle UTC', () => {128 process.env.TZ = 'UTC';129 130 const date = new Date('2023-01-01T12:00:00Z');131 const formatter = new DateFormatter();132 133 expect(formatter.formatLocal(date)).toBe('2023-01-01 12:00:00 UTC');134 });135});外部依赖测试
外部依赖Mock策略
javascript
1// API依赖Mock2class ExternalServiceMocker {3 constructor() {4 this.mocks = new Map();5 this.interceptors = [];6 }7 8 // Mock HTTP请求9 mockHttpService(baseUrl) {10 const axios = require('axios');11 const MockAdapter = require('axios-mock-adapter');12 13 const mock = new MockAdapter(axios);14 this.mocks.set('http', mock);15 16 return {17 onGet: (url, response, status = 200) => {18 mock.onGet(`${baseUrl}${url}`).reply(status, response);19 },20 onPost: (url, response, status = 201) => {21 mock.onPost(`${baseUrl}${url}`).reply(status, response);22 },23 onPut: (url, response, status = 200) => {24 mock.onPut(`${baseUrl}${url}`).reply(status, response);25 },26 onDelete: (url, status = 204) => {27 mock.onDelete(`${baseUrl}${url}`).reply(status);28 },29 reset: () => mock.reset(),30 restore: () => mock.restore()31 };32 }33 34 // Mock WebSocket35 mockWebSocket() {36 const WS = require('jest-websocket-mock');37 const server = new WS('ws://localhost:8080');38 39 this.mocks.set('websocket', server);40 41 return {42 server,43 waitForConnection: () => server.connected,44 send: (message) => server.send(message),45 close: () => server.close(),46 cleanup: () => WS.clean()47 };48 }49 50 // Mock localStorage51 mockLocalStorage() {52 const localStorageMock = {53 getItem: jest.fn(),54 setItem: jest.fn(),55 removeItem: jest.fn(),56 clear: jest.fn(),57 length: 0,58 key: jest.fn()59 };60 61 Object.defineProperty(window, 'localStorage', {62 value: localStorageMock,63 writable: true64 });65 66 this.mocks.set('localStorage', localStorageMock);67 return localStorageMock;68 }69 70 // Mock 地理位置API71 mockGeolocation() {72 const mockGeolocation = {73 getCurrentPosition: jest.fn(),74 watchPosition: jest.fn(),75 clearWatch: jest.fn()76 };77 78 Object.defineProperty(navigator, 'geolocation', {79 value: mockGeolocation,80 writable: true81 });82 83 this.mocks.set('geolocation', mockGeolocation);84 return mockGeolocation;85 }86 87 // Mock 文件API88 mockFileAPI() {89 const mockFile = (name, content, type = 'text/plain') => {90 return new File([content], name, { type });91 };92 93 const mockFileReader = {94 readAsText: jest.fn(),95 readAsDataURL: jest.fn(),96 result: null,97 onload: null,98 onerror: null99 };100 101 global.File = jest.fn().mockImplementation(mockFile);102 global.FileReader = jest.fn().mockImplementation(() => mockFileReader);103 104 this.mocks.set('file', { File: global.File, FileReader: mockFileReader });105 106 return { mockFile, mockFileReader };107 }108 109 // 清理所有Mock110 cleanup() {111 this.mocks.forEach((mock, key) => {112 switch (key) {113 case 'http':114 mock.restore();115 break;116 case 'websocket':117 mock.cleanup();118 break;119 default:120 // 其他清理逻辑121 break;122 }123 });124 this.mocks.clear();125 }126}127128// 使用示例129describe('External Dependencies', () => {130 const serviceMocker = new ExternalServiceMocker();131 132 afterEach(() => {133 serviceMocker.cleanup();134 });135 136 it('should handle API calls', async () => {137 const httpMock = serviceMocker.mockHttpService('https://api.example.com');138 139 httpMock.onGet('/users/1', { id: 1, name: 'John' });140 httpMock.onPost('/users', { id: 2, name: 'Jane' }, 201);141 142 const userService = new UserService('https://api.example.com');143 144 const user = await userService.getUser(1);145 expect(user).toEqual({ id: 1, name: 'John' });146 147 const newUser = await userService.createUser({ name: 'Jane' });148 expect(newUser).toEqual({ id: 2, name: 'Jane' });149 });150 151 it('should handle WebSocket connections', async () => {152 const wsMock = serviceMocker.mockWebSocket();153 154 const client = new WebSocketClient('ws://localhost:8080');155 await wsMock.waitForConnection();156 157 wsMock.send('Hello from server');158 159 expect(client.lastMessage).toBe('Hello from server');160 });161 162 it('should handle localStorage', () => {163 const localStorageMock = serviceMocker.mockLocalStorage();164 165 const storage = new StorageService();166 storage.set('key', 'value');167 168 expect(localStorageMock.setItem).toHaveBeenCalledWith('key', 'value');169 });170 171 it('should handle geolocation', (done) => {172 const geolocationMock = serviceMocker.mockGeolocation();173 174 geolocationMock.getCurrentPosition.mockImplementation((success) => {175 success({176 coords: {177 latitude: 40.7128,178 longitude: -74.0060179 }180 });181 });182 183 const locationService = new LocationService();184 locationService.getCurrentLocation((position) => {185 expect(position.coords.latitude).toBe(40.7128);186 expect(position.coords.longitude).toBe(-74.0060);187 done();188 });189 });190});
参与讨论