docs(testing): 新增微信小程序测试策略指南
- 添加完整的测试金字塔文档(70% 单元 + 20% 集成 + 5% E2E + 5% 手动) - 定义测试覆盖率目标和工具栈(Vitest + @vue/test-utils + MSW + Playwright) - 提供单元测试、集成测试、E2E 测试的完整代码示例 - 使用 H5 构建 + Playwright 作为小程序 E2E 测试的替代方案 - 添加实施路线图和最佳实践指南 参考文档: docs/testing-strategy.md
Showing
1 changed file
with
735 additions
and
0 deletions
docs/testing-strategy.md
0 → 100644
| 1 | +# 微信小程序测试策略指南 | ||
| 2 | + | ||
| 3 | +> **文档目的**:针对微信小程序的测试挑战,提供完整的测试分层策略和实施方案 | ||
| 4 | +> | ||
| 5 | +> **项目**:Manulife WeApp (Taro 4 + Vue 3) | ||
| 6 | +> | ||
| 7 | +> **最后更新**:2026-02-22 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +## 📊 测试金字塔 | ||
| 12 | + | ||
| 13 | +本项目采用分层测试策略,各层测试占比: | ||
| 14 | + | ||
| 15 | +``` | ||
| 16 | + /\ | ||
| 17 | + / \ | ||
| 18 | + / \ E2E 测试 (5%) | ||
| 19 | + / \ - 关键流程验证 | ||
| 20 | + / \ - H5 + Playwright | ||
| 21 | + /__________\ | ||
| 22 | + / \ | ||
| 23 | + / 集成测试 \ 集成测试 (10%) | ||
| 24 | +/ (20% 覆盖率) \ - API 集成 | ||
| 25 | +/________________\ - 完整用户流程 | ||
| 26 | +/ \ | ||
| 27 | +/ 单元测试 (70%) \ 单元测试 (70%) | ||
| 28 | +/ - Composables | ||
| 29 | +/ - Utils | ||
| 30 | +/ - Components | ||
| 31 | +``` | ||
| 32 | + | ||
| 33 | +### 测试覆盖率目标 | ||
| 34 | + | ||
| 35 | +| 层级 | 目标覆盖率 | 工具 | | ||
| 36 | +|------|-----------|------| | ||
| 37 | +| 单元测试 | 70% | Vitest + @vue/test-utils | | ||
| 38 | +| 集成测试 | 20% | Vitest + MSW | | ||
| 39 | +| E2E 测试 | 5% | Playwright (H5) | | ||
| 40 | +| 手动测试 | 5% | 真机测试 | | ||
| 41 | + | ||
| 42 | +--- | ||
| 43 | + | ||
| 44 | +## 🧪 现有测试基础设施 | ||
| 45 | + | ||
| 46 | +### 已配置工具 | ||
| 47 | + | ||
| 48 | +**测试框架**: | ||
| 49 | +- Vitest v1.6.0 - 测试运行器 | ||
| 50 | +- @vue/test-utils v2.4.6 - Vue 组件测试 | ||
| 51 | +- happy-dom v14.12.0 - 浏览器环境模拟 | ||
| 52 | + | ||
| 53 | +**测试配置** (`vitest.config.js`): | ||
| 54 | +```javascript | ||
| 55 | +export default defineConfig({ | ||
| 56 | + plugins: [ignoreCssPlugin(), vue()], | ||
| 57 | + test: { | ||
| 58 | + environment: 'happy-dom', | ||
| 59 | + css: true, | ||
| 60 | + globals: true, | ||
| 61 | + setupFiles: ['./test/setup.ts' | ||
| 62 | + } | ||
| 63 | +}) | ||
| 64 | +``` | ||
| 65 | + | ||
| 66 | +**现有测试文件**: | ||
| 67 | +``` | ||
| 68 | +src/ | ||
| 69 | +├── composables/__tests__/ | ||
| 70 | +│ └── usePlanView.integration.test.js # 计划视图集成测试 | ||
| 71 | +├── utils/__tests__/ | ||
| 72 | +│ └── planFieldValidation.test.js # 字段验证单元测试 | ||
| 73 | +├── pages/__tests__/ | ||
| 74 | +│ ├── index.test.js # 首页测试 | ||
| 75 | +│ ├── message-detail.test.js # 消息详情测试 | ||
| 76 | +│ ├── plan.test.js # 计划书测试 | ||
| 77 | +│ ├── plan-submit-result.test.js # 提交结果测试 | ||
| 78 | +│ └── product-center.test.js # 产品中心测试 | ||
| 79 | +└── __tests__/ | ||
| 80 | + └── tools.test.js # 工具函数测试 | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +--- | ||
| 84 | + | ||
| 85 | +## ✅ 单元测试(70%)- 核心重点 | ||
| 86 | + | ||
| 87 | +### 测试原则 | ||
| 88 | + | ||
| 89 | +- **单一职责**:每个测试只验证一个功能点 | ||
| 90 | +- **隔离性**:测试之间互不依赖 | ||
| 91 | +- **可重复性**:多次运行结果一致 | ||
| 92 | +- **快速执行**:单个测试 < 100ms | ||
| 93 | + | ||
| 94 | +### 测试重点 | ||
| 95 | + | ||
| 96 | +#### 1. Composables 测试 | ||
| 97 | + | ||
| 98 | +**示例:usePermission 测试** | ||
| 99 | + | ||
| 100 | +```javascript | ||
| 101 | +// src/composables/__tests__/usePermission.test.js | ||
| 102 | +import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| 103 | +import { usePermission } from '../usePermission' | ||
| 104 | +import * as Taro from '@tarojs/taro' | ||
| 105 | + | ||
| 106 | +// Mock Taro API | ||
| 107 | +vi.mock('@tarojs/taro', () => ({ | ||
| 108 | + default: { | ||
| 109 | + showModal: vi.fn(), | ||
| 110 | + navigateTo: vi.fn(), | ||
| 111 | + showToast: vi.fn() | ||
| 112 | + } | ||
| 113 | +})) | ||
| 114 | + | ||
| 115 | +describe('usePermission', () => { | ||
| 116 | + beforeEach(() => { | ||
| 117 | + vi.clearAllMocks() | ||
| 118 | + }) | ||
| 119 | + | ||
| 120 | + it('should allow access when user is logged in', async () => { | ||
| 121 | + const { hasPermission } = usePermission() | ||
| 122 | + | ||
| 123 | + // Mock 登录状态 | ||
| 124 | + const { isLoggedIn } = useUserStore() | ||
| 125 | + isLoggedIn.value = true | ||
| 126 | + | ||
| 127 | + const result = await hasPermission() | ||
| 128 | + | ||
| 129 | + expect(result).toBe(true) | ||
| 130 | + expect(Taro.showModal).not.toHaveBeenCalled() | ||
| 131 | + }) | ||
| 132 | + | ||
| 133 | + it('should show login modal when user is not logged in', async () => { | ||
| 134 | + const { hasPermission } = usePermission() | ||
| 135 | + | ||
| 136 | + // Mock 未登录状态 | ||
| 137 | + const { isLoggedIn } = useUserStore() | ||
| 138 | + isLoggedIn.value = false | ||
| 139 | + | ||
| 140 | + const result = await hasPermission() | ||
| 141 | + | ||
| 142 | + expect(result).toBe(false) | ||
| 143 | + expect(Taro.showModal).toHaveBeenCalledWith({ | ||
| 144 | + title: '提示', | ||
| 145 | + content: '请先登录' | ||
| 146 | + }) | ||
| 147 | + }) | ||
| 148 | +}) | ||
| 149 | +``` | ||
| 150 | + | ||
| 151 | +#### 2. 工具函数测试 | ||
| 152 | + | ||
| 153 | +**示例:字段验证测试** | ||
| 154 | + | ||
| 155 | +```javascript | ||
| 156 | +// src/utils/__tests__/planFieldValidation.test.js | ||
| 157 | +import { describe, it, expect } from 'vitest' | ||
| 158 | +import { validateField } from '../planFieldValidation' | ||
| 159 | + | ||
| 160 | +describe('planFieldValidation', () => { | ||
| 161 | + describe('required validation', () => { | ||
| 162 | + it('should pass when value is provided', () => { | ||
| 163 | + const result = validateField({ value: 'test' }, { required: true }) | ||
| 164 | + expect(result.valid).toBe(true) | ||
| 165 | + }) | ||
| 166 | + | ||
| 167 | + it('should fail when value is empty', () => { | ||
| 168 | + const result = validateField({ value: '' }, { required: true }) | ||
| 169 | + expect(result.valid).toBe(false) | ||
| 170 | + expect(result.error).toContain('必填') | ||
| 171 | + }) | ||
| 172 | + }) | ||
| 173 | + | ||
| 174 | + describe('pattern validation', () => { | ||
| 175 | + it('should validate email format', () => { | ||
| 176 | + const result = validateField( | ||
| 177 | + { value: 'invalid-email' }, | ||
| 178 | + { pattern: 'email' } | ||
| 179 | + ) | ||
| 180 | + expect(result.valid).toBe(false) | ||
| 181 | + }) | ||
| 182 | + | ||
| 183 | + it('should validate phone format', () => { | ||
| 184 | + const result = validateField( | ||
| 185 | + { value: '12345678901' }, | ||
| 186 | + { pattern: 'phone' } | ||
| 187 | + ) | ||
| 188 | + expect(result.valid).toBe(false) | ||
| 189 | + }) | ||
| 190 | + }) | ||
| 191 | + | ||
| 192 | + describe('range validation', () => { | ||
| 193 | + it('should validate min value', () => { | ||
| 194 | + const result = validateField( | ||
| 195 | + { value: 5 }, | ||
| 196 | + { type: 'number', min: 10 } | ||
| 197 | + ) | ||
| 198 | + expect(result.valid).toBe(false) | ||
| 199 | + expect(result.error).toContain('最小值为 10') | ||
| 200 | + }) | ||
| 201 | + | ||
| 202 | + it('should validate max value', () => { | ||
| 203 | + const result = validateField( | ||
| 204 | + { value: 200 }, | ||
| 205 | + { type: 'number', max: 100 } | ||
| 206 | + ) | ||
| 207 | + expect(result.valid).toBe(false) | ||
| 208 | + expect(result.error).toContain('最大值为 100') | ||
| 209 | + }) | ||
| 210 | + }) | ||
| 211 | +}) | ||
| 212 | +``` | ||
| 213 | + | ||
| 214 | +#### 3. 组件测试 | ||
| 215 | + | ||
| 216 | +**示例:ProductCard 组件测试** | ||
| 217 | + | ||
| 218 | +```javascript | ||
| 219 | +// src/components/__tests__/ProductCard.test.js | ||
| 220 | +import { describe, it, expect, vi } from 'vitest' | ||
| 221 | +import { mount } from '@vue/test-utils' | ||
| 222 | +import ProductCard from '../ProductCard.vue' | ||
| 223 | + | ||
| 224 | +describe('ProductCard', () => { | ||
| 225 | + const mockProduct = { | ||
| 226 | + id: 1, | ||
| 227 | + title: '测试产品', | ||
| 228 | + cover: 'https://example.com/cover.jpg', | ||
| 229 | + tags: ['热销', '推荐'] | ||
| 230 | + } | ||
| 231 | + | ||
| 232 | + it('should render product information correctly', () => { | ||
| 233 | + const wrapper = mount(ProductCard, { | ||
| 234 | + props: { product: mockProduct } | ||
| 235 | + }) | ||
| 236 | + | ||
| 237 | + expect(wrapper.text()).toContain('测试产品') | ||
| 238 | + expect(wrapper.find('img').attributes('src')).toBe('https://example.com/cover.jpg') | ||
| 239 | + }) | ||
| 240 | + | ||
| 241 | + it('should emit click event when clicked', async () => { | ||
| 242 | + const wrapper = mount(ProductCard, { | ||
| 243 | + props: { product: mockProduct } | ||
| 244 | + }) | ||
| 245 | + | ||
| 246 | + await wrapper.trigger('click') | ||
| 247 | + | ||
| 248 | + expect(wrapper.emitted('click')).toBeTruthy() | ||
| 249 | + expect(wrapper.emitted('click')[0]).toEqual([mockProduct]) | ||
| 250 | + }) | ||
| 251 | + | ||
| 252 | + it('should show collect button when user is logged in', () => { | ||
| 253 | + const wrapper = mount(ProductCard, { | ||
| 254 | + props: { | ||
| 255 | + product: mockProduct, | ||
| 256 | + isLoggedIn: true | ||
| 257 | + } | ||
| 258 | + }) | ||
| 259 | + | ||
| 260 | + expect(wrapper.find('.collect-button').exists()).toBe(true) | ||
| 261 | + }) | ||
| 262 | + | ||
| 263 | + it('should hide collect button when user is not logged in', () => { | ||
| 264 | + const wrapper = mount(ProductCard, { | ||
| 265 | + props: { | ||
| 266 | + product: mockProduct, | ||
| 267 | + isLoggedIn: false | ||
| 268 | + } | ||
| 269 | + }) | ||
| 270 | + | ||
| 271 | + expect(wrapper.find('.collect-button').exists()).toBe(false) | ||
| 272 | + }) | ||
| 273 | +}) | ||
| 274 | +``` | ||
| 275 | + | ||
| 276 | +--- | ||
| 277 | + | ||
| 278 | +## 🔗 集成测试(10%)- 流程验证 | ||
| 279 | + | ||
| 280 | +### 测试目标 | ||
| 281 | + | ||
| 282 | +- 验证多个模块协作是否正常 | ||
| 283 | +- 测试完整的用户流程 | ||
| 284 | +- 验证 API 集成 | ||
| 285 | + | ||
| 286 | +### 测试策略 | ||
| 287 | + | ||
| 288 | +#### 1. API 集成测试 | ||
| 289 | + | ||
| 290 | +**使用 MSW (Mock Service Worker)** | ||
| 291 | + | ||
| 292 | +```javascript | ||
| 293 | +// src/api/__tests__/planAPI.integration.test.js | ||
| 294 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest' | ||
| 295 | +import { HttpResponse, http } from 'msw' | ||
| 296 | +import { submitPlanAPI, getPlanListAPI } from '../index' | ||
| 297 | + | ||
| 298 | +// Setup MSW | ||
| 299 | +const { mock, unmock } = http | ||
| 300 | + | ||
| 301 | +beforeEach(() => { | ||
| 302 | + mock.resetHandlers() | ||
| 303 | +}) | ||
| 304 | + | ||
| 305 | +afterEach(() => { | ||
| 306 | + unmock() | ||
| 307 | +}) | ||
| 308 | + | ||
| 309 | +describe('Plan API Integration', () => { | ||
| 310 | + const mockPlanData = { | ||
| 311 | + id: 1, | ||
| 312 | + name: '测试计划书', | ||
| 313 | + status: 'pending' | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + it('should submit plan successfully', async () => { | ||
| 317 | + mock.post('/srv/?a=submit_plan', () => { | ||
| 318 | + return HttpResponse.json({ | ||
| 319 | + code: 1, | ||
| 320 | + data: mockPlanData, | ||
| 321 | + msg: '提交成功' | ||
| 322 | + }) | ||
| 323 | + }) | ||
| 324 | + | ||
| 325 | + const result = await submitPlanAPI({ | ||
| 326 | + products: [{ id: 1, amount: 100 }] | ||
| 327 | + }) | ||
| 328 | + | ||
| 329 | + expect(result.code).toBe(1) | ||
| 330 | + expect(result.data).toEqual(mockPlanData) | ||
| 331 | + }) | ||
| 332 | + | ||
| 333 | + it('should handle API error gracefully', async () => { | ||
| 334 | + mock.post('/srv/?a=submit_plan', () => { | ||
| 335 | + return HttpResponse.json({ | ||
| 336 | + code: 0, | ||
| 337 | + msg: '网络错误' | ||
| 338 | + }, { status: 500 }) | ||
| 339 | + }) | ||
| 340 | + | ||
| 341 | + const result = await submitPlanAPI({ | ||
| 342 | + products: [] | ||
| 343 | + }) | ||
| 344 | + | ||
| 345 | + expect(result.code).not.toBe(1) | ||
| 346 | + expect(result.msg).toBeTruthy() | ||
| 347 | + }) | ||
| 348 | + | ||
| 349 | + it('should retry on 401 error', async () => { | ||
| 350 | + let requestCount = 0 | ||
| 351 | + | ||
| 352 | + mock.post('/srv/?a=submit_plan', () => { | ||
| 353 | + requestCount++ | ||
| 354 | + if (requestCount === 1) { | ||
| 355 | + return HttpResponse.json({ code: -1 }, { status: 401 }) | ||
| 356 | + } | ||
| 357 | + return HttpResponse.json({ | ||
| 358 | + code: 1, | ||
| 359 | + data: mockPlanData | ||
| 360 | + }) | ||
| 361 | + }) | ||
| 362 | + | ||
| 363 | + const result = await submitPlanAPI({ | ||
| 364 | + products: [] | ||
| 365 | + }) | ||
| 366 | + | ||
| 367 | + expect(result.code).toBe(1) | ||
| 368 | + expect(requestCount).toBe(2) // 第一次 401,第二次成功 | ||
| 369 | + }) | ||
| 370 | +}) | ||
| 371 | +``` | ||
| 372 | + | ||
| 373 | +#### 2. Composable 集成测试 | ||
| 374 | + | ||
| 375 | +**示例:完整用户流程测试** | ||
| 376 | + | ||
| 377 | +```javascript | ||
| 378 | +// src/composables/__tests__/usePlanView.integration.test.js | ||
| 379 | +import { describe, it, expect, beforeEach, vi } from 'vitest' | ||
| 380 | +import { usePlanView } from '../usePlanView' | ||
| 381 | +import { submitPlanAPI } from '@/api' | ||
| 382 | +import * as Taro from '@tarojs/taro' | ||
| 383 | + | ||
| 384 | +vi.mock('@tarojs/taro') | ||
| 385 | +vi.mock('@/api') | ||
| 386 | + | ||
| 387 | +describe('usePlanView Integration', () => { | ||
| 388 | + beforeEach(() => { | ||
| 389 | + vi.clearAllMocks() | ||
| 390 | + }) | ||
| 391 | + | ||
| 392 | + it('should complete plan submission flow', async () => { | ||
| 393 | + const { selectedProducts, submitPlan } = usePlanView() | ||
| 394 | + | ||
| 395 | + // 添加产品 | ||
| 396 | + selectedProducts.value = [ | ||
| 397 | + { id: 1, name: '产品A', amount: 100 }, | ||
| 398 | + { id: 2, name: '产品B', amount: 200 } | ||
| 399 | + ] | ||
| 400 | + | ||
| 401 | + // Mock API | ||
| 402 | + submitPlanAPI.mockResolvedValue({ | ||
| 403 | + code: 1, | ||
| 404 | + data: { planId: 123 } | ||
| 405 | + }) | ||
| 406 | + | ||
| 407 | + // 提交 | ||
| 408 | + const result = await submitPlan() | ||
| 409 | + | ||
| 410 | + expect(result.success).toBe(true) | ||
| 411 | + expect(result.data.planId).toBe(123) | ||
| 412 | + expect(submitPlanAPI).toHaveBeenCalledWith({ | ||
| 413 | + products: selectedProducts.value | ||
| 414 | + }) | ||
| 415 | + }) | ||
| 416 | + | ||
| 417 | + it('should show loading state during submission', async () => { | ||
| 418 | + const { loading, submitPlan } = usePlanView() | ||
| 419 | + | ||
| 420 | + submitPlanAPI.mockImplementation(() => { | ||
| 421 | + return new Promise(resolve => { | ||
| 422 | + loading.value = true | ||
| 423 | + setTimeout(() => { | ||
| 424 | + loading.value = false | ||
| 425 | + resolve({ code: 1, data: {} }) | ||
| 426 | + }, 1000) | ||
| 427 | + }) | ||
| 428 | + }) | ||
| 429 | + | ||
| 430 | + const promise = submitPlan() | ||
| 431 | + expect(loading.value).toBe(true) | ||
| 432 | + | ||
| 433 | + await promise | ||
| 434 | + expect(loading.value).toBe(false) | ||
| 435 | + }) | ||
| 436 | +}) | ||
| 437 | +``` | ||
| 438 | + | ||
| 439 | +--- | ||
| 440 | + | ||
| 441 | +## 🎭 E2E 测试(5%)- 关键流程 | ||
| 442 | + | ||
| 443 | +### 小程序 E2E 测试方案 | ||
| 444 | + | ||
| 445 | +由于小程序真机测试困难,我们采用 **H5 构建 + Playwright** 的替代方案: | ||
| 446 | + | ||
| 447 | +#### 方案对比 | ||
| 448 | + | ||
| 449 | +| 方案 | 优势 | 劣势 | 推荐指数 | | ||
| 450 | +|------|------|------|---------| | ||
| 451 | +| **微信开发者工具自动化** | 原生支持 | 功能有限,不稳定 | ⭐⭐ | | ||
| 452 | +| **H5 + Playwright** | 成熟稳定,调试方便 | 非原生环境 | ⭐⭐⭐⭐⭐ | | ||
| 453 | +| **真机云测试** | 真实环境 | 成本高,配置复杂 | ⭐⭐⭐ | | ||
| 454 | + | ||
| 455 | +#### H5 构建 + Playwright 实施 | ||
| 456 | + | ||
| 457 | +**1. 创建 E2E 测试文件** | ||
| 458 | + | ||
| 459 | +```javascript | ||
| 460 | +// e2e/plan-workflow.spec.js | ||
| 461 | +import { test, expect } from '@playwright/test' | ||
| 462 | + | ||
| 463 | +test.describe('计划书提交流程', () => { | ||
| 464 | + test.beforeEach(async ({ page }) => { | ||
| 465 | + // 导航到首页 | ||
| 466 | + await page.goto('/#/') | ||
| 467 | + | ||
| 468 | + // 登录(如果需要) | ||
| 469 | + const loginButton = page.locator('text=登录') | ||
| 470 | + if (await loginButton.isVisible()) { | ||
| 471 | + await loginButton.click() | ||
| 472 | + // 填写登录信息... | ||
| 473 | + } | ||
| 474 | + }) | ||
| 475 | + | ||
| 476 | + test('should complete plan submission', async ({ page }) => { | ||
| 477 | + // 1. 进入产品中心 | ||
| 478 | + await page.click('text=产品中心') | ||
| 479 | + await expect(page).toHaveURL(/.*product-center/) | ||
| 480 | + | ||
| 481 | + // 2. 选择产品 | ||
| 482 | + await page.click('[data-product-id="1"]') | ||
| 483 | + await page.click('text=加入计划书') | ||
| 484 | + | ||
| 485 | + // 3. 进入计划书页面 | ||
| 486 | + await page.click('text=计划书') | ||
| 487 | + await expect(page).toHaveURL(/.*plan/) | ||
| 488 | + | ||
| 489 | + // 4. 提交计划书 | ||
| 490 | + await page.click('text=提交计划书') | ||
| 491 | + | ||
| 492 | + // 验证成功提示 | ||
| 493 | + await expect(page.locator('text=提交成功')).toBeVisible() | ||
| 494 | + }) | ||
| 495 | + | ||
| 496 | + test('should show error when product list is empty', async ({ page }) => { | ||
| 497 | + await page.goto('/#/plan') | ||
| 498 | + | ||
| 499 | + // 空列表应该显示提示 | ||
| 500 | + await expect(page.locator('text=请先添加产品')).toBeVisible() | ||
| 501 | + }) | ||
| 502 | +}) | ||
| 503 | +``` | ||
| 504 | + | ||
| 505 | +**2. Playwright 配置** | ||
| 506 | + | ||
| 507 | +```javascript | ||
| 508 | +// playwright.config.js | ||
| 509 | +import { defineConfig } from '@playwright/test' | ||
| 510 | + | ||
| 511 | +export default defineConfig({ | ||
| 512 | + testDir: './e2e', | ||
| 513 | + fullyParallel: false, | ||
| 514 | + forbidOnly: false, | ||
| 515 | + retries: 1, | ||
| 516 | + | ||
| 517 | + use: { | ||
| 518 | + baseURL: 'http://localhost:5173/#/', | ||
| 519 | + headless: false, | ||
| 520 | + screenshot: 'only-on-failure', | ||
| 521 | + video: 'retain-on-failure', | ||
| 522 | + trace: 'retain-on-failure' | ||
| 523 | + }, | ||
| 524 | + | ||
| 525 | + projects: [ | ||
| 526 | + { | ||
| 527 | + name: 'chromium', | ||
| 528 | + use: { | ||
| 529 | + viewport: { width: 375, height: 667 }, // 小程序尺寸 | ||
| 530 | + locale: 'zh-CN' | ||
| 531 | + } | ||
| 532 | + } | ||
| 533 | + ] | ||
| 534 | +}) | ||
| 535 | +``` | ||
| 536 | + | ||
| 537 | +**3. 运行 E2E 测试** | ||
| 538 | + | ||
| 539 | +```bash | ||
| 540 | +# 1. 构建 H5 版本 | ||
| 541 | +pnpm dev:h5 | ||
| 542 | + | ||
| 543 | +# 2. 在另一个终端运行 E2E 测试 | ||
| 544 | +pnpm test:e2e | ||
| 545 | + | ||
| 546 | +# 3. 查看测试报告 | ||
| 547 | +# 自动生成在 playwright-report/ | ||
| 548 | +``` | ||
| 549 | + | ||
| 550 | +--- | ||
| 551 | + | ||
| 552 | +## 📋 实施路线图 | ||
| 553 | + | ||
| 554 | +### 短期目标(1-2 周)✅ | ||
| 555 | + | ||
| 556 | +**目标**:完善单元测试覆盖率 | ||
| 557 | + | ||
| 558 | +- [x] 为所有 Composables 添加测试 | ||
| 559 | +- [ ] 为工具函数添加测试 | ||
| 560 | +- [ ] 为核心组件添加测试 | ||
| 561 | +- [ ] 达到 70% 单元测试覆盖率 | ||
| 562 | + | ||
| 563 | +**命令**: | ||
| 564 | +```bash | ||
| 565 | +# 运行所有测试 | ||
| 566 | +pnpm test | ||
| 567 | + | ||
| 568 | +# 查看覆盖率报告 | ||
| 569 | +pnpm test:coverage | ||
| 570 | +``` | ||
| 571 | + | ||
| 572 | +### 中期目标(2-4 周) | ||
| 573 | + | ||
| 574 | +**目标**:添加集成测试 | ||
| 575 | + | ||
| 576 | +- [ ] 为关键 API 流程添加集成测试 | ||
| 577 | +- [ ] 为完整用户流程添加集成测试 | ||
| 578 | +- [ ] 配置 MSW 用于 API Mock | ||
| 579 | +- [ ] 达到 80% 测试覆盖率 | ||
| 580 | + | ||
| 581 | +**新增依赖**: | ||
| 582 | +```bash | ||
| 583 | +pnpm add -D msw | ||
| 584 | +``` | ||
| 585 | + | ||
| 586 | +### 长期目标(1-2 月) | ||
| 587 | + | ||
| 588 | +**目标**:完善 E2E 测试 | ||
| 589 | + | ||
| 590 | +- [ ] 配置 Playwright | ||
| 591 | +- [ ] 为关键流程编写 E2E 测试 | ||
| 592 | +- [ ] 配置 CI/CD 自动测试 | ||
| 593 | +- [ ] 达到 90% 测试覆盖率 | ||
| 594 | + | ||
| 595 | +**新增依赖**: | ||
| 596 | +```bash | ||
| 597 | +pnpm add -D @playwright/test | ||
| 598 | +``` | ||
| 599 | + | ||
| 600 | +--- | ||
| 601 | + | ||
| 602 | +## ✅ 测试最佳实践 | ||
| 603 | + | ||
| 604 | +### 1. Mock 策略 | ||
| 605 | + | ||
| 606 | +**Mock Taro API**: | ||
| 607 | +```javascript | ||
| 608 | +vi.mock('@tarojs/taro', () => ({ | ||
| 609 | + default: { | ||
| 610 | + navigateTo: vi.fn(), | ||
| 611 | + showModal: vi.fn(), | ||
| 612 | + showToast: vi.fn(), | ||
| 613 | + getSystemInfo: vi.fn(() => Promise.resolve({ | ||
| 614 | + model: 'iPhone 12' | ||
| 615 | + })) | ||
| 616 | + } | ||
| 617 | +})) | ||
| 618 | +``` | ||
| 619 | + | ||
| 620 | +**Mock Pinia Store**: | ||
| 621 | +```javascript | ||
| 622 | +import { setActivePinia, createPinia } from 'pinia' | ||
| 623 | +import { createTestingApp } from '@pinia/testing' | ||
| 624 | + | ||
| 625 | +const testingApp = createTestingApp({ | ||
| 626 | + state: { | ||
| 627 | + user: { | ||
| 628 | + isLoggedIn: true, | ||
| 629 | + userInfo: { name: 'Test User' } | ||
| 630 | + } | ||
| 631 | + } | ||
| 632 | +}) | ||
| 633 | +``` | ||
| 634 | + | ||
| 635 | +### 2. 测试命名规范 | ||
| 636 | + | ||
| 637 | +**使用描述性命名**: | ||
| 638 | +```javascript | ||
| 639 | +// ✅ GOOD | ||
| 640 | +it('should show error message when form is invalid') | ||
| 641 | + | ||
| 642 | +// ❌ BAD | ||
| 643 | +it('test 1') | ||
| 644 | +``` | ||
| 645 | + | ||
| 646 | +### 3. AAA 模式(Arrange-Act-Assert) | ||
| 647 | + | ||
| 648 | +```javascript | ||
| 649 | +it('should add product to cart', () => { | ||
| 650 | + // Arrange - 准备测试数据 | ||
| 651 | + const product = { id: 1, name: '产品A' } | ||
| 652 | + const cart = [] | ||
| 653 | + | ||
| 654 | + // Act - 执行操作 | ||
| 655 | + const result = addToCart(product, cart) | ||
| 656 | + | ||
| 657 | + // Assert - 验证结果 | ||
| 658 | + expect(result).toContain(product) | ||
| 659 | + expect(cart.length).toBe(1) | ||
| 660 | +}) | ||
| 661 | +``` | ||
| 662 | + | ||
| 663 | +### 4. 边界测试 | ||
| 664 | + | ||
| 665 | +**测试边界值和异常情况**: | ||
| 666 | +```javascript | ||
| 667 | +describe('boundary testing', () => { | ||
| 668 | + it('should handle empty input') | ||
| 669 | + it('should handle null input') | ||
| 670 | + it('should handle max value') | ||
| 671 | + it('should handle min value') | ||
| 672 | + it('should handle network timeout') | ||
| 673 | +}) | ||
| 674 | +``` | ||
| 675 | + | ||
| 676 | +--- | ||
| 677 | + | ||
| 678 | +## 🔍 测试检查清单 | ||
| 679 | + | ||
| 680 | +### 提交代码前 | ||
| 681 | + | ||
| 682 | +- [ ] 所有新功能有对应的单元测试 | ||
| 683 | +- [ ] 测试覆盖率不降低 | ||
| 684 | +- [ ] 所有测试通过 (`pnpm test`) | ||
| 685 | +- [ ] 无跳过的测试 (`test.skip()`) | ||
| 686 | + | ||
| 687 | +### 发布前 | ||
| 688 | + | ||
| 689 | +- [ ] E2E 测试通过 | ||
| 690 | +- [ ] 关键流程手动测试通过 | ||
| 691 | +- [ ] 测试覆盖率 ≥ 80% | ||
| 692 | +- [ ] 性能测试通过 | ||
| 693 | + | ||
| 694 | +--- | ||
| 695 | + | ||
| 696 | +## 🛠️ 常用测试命令 | ||
| 697 | + | ||
| 698 | +```bash | ||
| 699 | +# 运行所有测试 | ||
| 700 | +pnpm test | ||
| 701 | + | ||
| 702 | +# 运行特定测试文件 | ||
| 703 | +pnpm test usePermission.test.js | ||
| 704 | + | ||
| 705 | +# 运行匹配的测试 | ||
| 706 | +pnpm test --validation | ||
| 707 | + | ||
| 708 | +# 监听模式(开发时使用) | ||
| 709 | +pnpm test --watch | ||
| 710 | + | ||
| 711 | +# 覆盖率报告 | ||
| 712 | +pnpm test:coverage | ||
| 713 | + | ||
| 714 | +# UI 模式(交互式调试) | ||
| 715 | +pnpm test --ui | ||
| 716 | +``` | ||
| 717 | + | ||
| 718 | +--- | ||
| 719 | + | ||
| 720 | +## 📚 相关文档 | ||
| 721 | + | ||
| 722 | +- [Vitest 官方文档](https://vitest.dev/) | ||
| 723 | +- [@vue/test-utils 文档](https://test-utils.vuejs.org/) | ||
| 724 | +- [Playwright 文档](https://playwright.dev/) | ||
| 725 | +- [Pinia Testing 文档](https://pinia.vuejs.org/cookbook/testing/) | ||
| 726 | +- [项目 CLAUDE.md](../../CLAUDE.md) | ||
| 727 | + | ||
| 728 | +--- | ||
| 729 | + | ||
| 730 | +**维护说明**: | ||
| 731 | +- 本文档随测试基础设施更新而维护 | ||
| 732 | +- 有新的测试模式或工具时,及时更新 | ||
| 733 | +- 定期审查测试覆盖率,确保测试质量 | ||
| 734 | + | ||
| 735 | +**最后更新**: 2026-02-22 |
-
Please register or login to post a comment