hookehuyr

docs(testing): 新增微信小程序测试策略指南

- 添加完整的测试金字塔文档(70% 单元 + 20% 集成 + 5% E2E + 5% 手动)
- 定义测试覆盖率目标和工具栈(Vitest + @vue/test-utils + MSW + Playwright)
- 提供单元测试、集成测试、E2E 测试的完整代码示例
- 使用 H5 构建 + Playwright 作为小程序 E2E 测试的替代方案
- 添加实施路线图和最佳实践指南

参考文档: docs/testing-strategy.md
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