hookehuyr

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

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

参考文档: docs/testing-strategy.md
# 微信小程序测试策略指南
> **文档目的**:针对微信小程序的测试挑战,提供完整的测试分层策略和实施方案
>
> **项目**:Manulife WeApp (Taro 4 + Vue 3)
>
> **最后更新**:2026-02-22
---
## 📊 测试金字塔
本项目采用分层测试策略,各层测试占比:
```
/\
/ \
/ \ E2E 测试 (5%)
/ \ - 关键流程验证
/ \ - H5 + Playwright
/__________\
/ \
/ 集成测试 \ 集成测试 (10%)
/ (20% 覆盖率) \ - API 集成
/________________\ - 完整用户流程
/ \
/ 单元测试 (70%) \ 单元测试 (70%)
/ - Composables
/ - Utils
/ - Components
```
### 测试覆盖率目标
| 层级 | 目标覆盖率 | 工具 |
|------|-----------|------|
| 单元测试 | 70% | Vitest + @vue/test-utils |
| 集成测试 | 20% | Vitest + MSW |
| E2E 测试 | 5% | Playwright (H5) |
| 手动测试 | 5% | 真机测试 |
---
## 🧪 现有测试基础设施
### 已配置工具
**测试框架**
- Vitest v1.6.0 - 测试运行器
- @vue/test-utils v2.4.6 - Vue 组件测试
- happy-dom v14.12.0 - 浏览器环境模拟
**测试配置** (`vitest.config.js`):
```javascript
export default defineConfig({
plugins: [ignoreCssPlugin(), vue()],
test: {
environment: 'happy-dom',
css: true,
globals: true,
setupFiles: ['./test/setup.ts'
}
})
```
**现有测试文件**
```
src/
├── composables/__tests__/
│ └── usePlanView.integration.test.js # 计划视图集成测试
├── utils/__tests__/
│ └── planFieldValidation.test.js # 字段验证单元测试
├── pages/__tests__/
│ ├── index.test.js # 首页测试
│ ├── message-detail.test.js # 消息详情测试
│ ├── plan.test.js # 计划书测试
│ ├── plan-submit-result.test.js # 提交结果测试
│ └── product-center.test.js # 产品中心测试
└── __tests__/
└── tools.test.js # 工具函数测试
```
---
## ✅ 单元测试(70%)- 核心重点
### 测试原则
- **单一职责**:每个测试只验证一个功能点
- **隔离性**:测试之间互不依赖
- **可重复性**:多次运行结果一致
- **快速执行**:单个测试 < 100ms
### 测试重点
#### 1. Composables 测试
**示例:usePermission 测试**
```javascript
// src/composables/__tests__/usePermission.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { usePermission } from '../usePermission'
import * as Taro from '@tarojs/taro'
// Mock Taro API
vi.mock('@tarojs/taro', () => ({
default: {
showModal: vi.fn(),
navigateTo: vi.fn(),
showToast: vi.fn()
}
}))
describe('usePermission', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should allow access when user is logged in', async () => {
const { hasPermission } = usePermission()
// Mock 登录状态
const { isLoggedIn } = useUserStore()
isLoggedIn.value = true
const result = await hasPermission()
expect(result).toBe(true)
expect(Taro.showModal).not.toHaveBeenCalled()
})
it('should show login modal when user is not logged in', async () => {
const { hasPermission } = usePermission()
// Mock 未登录状态
const { isLoggedIn } = useUserStore()
isLoggedIn.value = false
const result = await hasPermission()
expect(result).toBe(false)
expect(Taro.showModal).toHaveBeenCalledWith({
title: '提示',
content: '请先登录'
})
})
})
```
#### 2. 工具函数测试
**示例:字段验证测试**
```javascript
// src/utils/__tests__/planFieldValidation.test.js
import { describe, it, expect } from 'vitest'
import { validateField } from '../planFieldValidation'
describe('planFieldValidation', () => {
describe('required validation', () => {
it('should pass when value is provided', () => {
const result = validateField({ value: 'test' }, { required: true })
expect(result.valid).toBe(true)
})
it('should fail when value is empty', () => {
const result = validateField({ value: '' }, { required: true })
expect(result.valid).toBe(false)
expect(result.error).toContain('必填')
})
})
describe('pattern validation', () => {
it('should validate email format', () => {
const result = validateField(
{ value: 'invalid-email' },
{ pattern: 'email' }
)
expect(result.valid).toBe(false)
})
it('should validate phone format', () => {
const result = validateField(
{ value: '12345678901' },
{ pattern: 'phone' }
)
expect(result.valid).toBe(false)
})
})
describe('range validation', () => {
it('should validate min value', () => {
const result = validateField(
{ value: 5 },
{ type: 'number', min: 10 }
)
expect(result.valid).toBe(false)
expect(result.error).toContain('最小值为 10')
})
it('should validate max value', () => {
const result = validateField(
{ value: 200 },
{ type: 'number', max: 100 }
)
expect(result.valid).toBe(false)
expect(result.error).toContain('最大值为 100')
})
})
})
```
#### 3. 组件测试
**示例:ProductCard 组件测试**
```javascript
// src/components/__tests__/ProductCard.test.js
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProductCard from '../ProductCard.vue'
describe('ProductCard', () => {
const mockProduct = {
id: 1,
title: '测试产品',
cover: 'https://example.com/cover.jpg',
tags: ['热销', '推荐']
}
it('should render product information correctly', () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct }
})
expect(wrapper.text()).toContain('测试产品')
expect(wrapper.find('img').attributes('src')).toBe('https://example.com/cover.jpg')
})
it('should emit click event when clicked', async () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct }
})
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
expect(wrapper.emitted('click')[0]).toEqual([mockProduct])
})
it('should show collect button when user is logged in', () => {
const wrapper = mount(ProductCard, {
props: {
product: mockProduct,
isLoggedIn: true
}
})
expect(wrapper.find('.collect-button').exists()).toBe(true)
})
it('should hide collect button when user is not logged in', () => {
const wrapper = mount(ProductCard, {
props: {
product: mockProduct,
isLoggedIn: false
}
})
expect(wrapper.find('.collect-button').exists()).toBe(false)
})
})
```
---
## 🔗 集成测试(10%)- 流程验证
### 测试目标
- 验证多个模块协作是否正常
- 测试完整的用户流程
- 验证 API 集成
### 测试策略
#### 1. API 集成测试
**使用 MSW (Mock Service Worker)**
```javascript
// src/api/__tests__/planAPI.integration.test.js
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { HttpResponse, http } from 'msw'
import { submitPlanAPI, getPlanListAPI } from '../index'
// Setup MSW
const { mock, unmock } = http
beforeEach(() => {
mock.resetHandlers()
})
afterEach(() => {
unmock()
})
describe('Plan API Integration', () => {
const mockPlanData = {
id: 1,
name: '测试计划书',
status: 'pending'
}
it('should submit plan successfully', async () => {
mock.post('/srv/?a=submit_plan', () => {
return HttpResponse.json({
code: 1,
data: mockPlanData,
msg: '提交成功'
})
})
const result = await submitPlanAPI({
products: [{ id: 1, amount: 100 }]
})
expect(result.code).toBe(1)
expect(result.data).toEqual(mockPlanData)
})
it('should handle API error gracefully', async () => {
mock.post('/srv/?a=submit_plan', () => {
return HttpResponse.json({
code: 0,
msg: '网络错误'
}, { status: 500 })
})
const result = await submitPlanAPI({
products: []
})
expect(result.code).not.toBe(1)
expect(result.msg).toBeTruthy()
})
it('should retry on 401 error', async () => {
let requestCount = 0
mock.post('/srv/?a=submit_plan', () => {
requestCount++
if (requestCount === 1) {
return HttpResponse.json({ code: -1 }, { status: 401 })
}
return HttpResponse.json({
code: 1,
data: mockPlanData
})
})
const result = await submitPlanAPI({
products: []
})
expect(result.code).toBe(1)
expect(requestCount).toBe(2) // 第一次 401,第二次成功
})
})
```
#### 2. Composable 集成测试
**示例:完整用户流程测试**
```javascript
// src/composables/__tests__/usePlanView.integration.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { usePlanView } from '../usePlanView'
import { submitPlanAPI } from '@/api'
import * as Taro from '@tarojs/taro'
vi.mock('@tarojs/taro')
vi.mock('@/api')
describe('usePlanView Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should complete plan submission flow', async () => {
const { selectedProducts, submitPlan } = usePlanView()
// 添加产品
selectedProducts.value = [
{ id: 1, name: '产品A', amount: 100 },
{ id: 2, name: '产品B', amount: 200 }
]
// Mock API
submitPlanAPI.mockResolvedValue({
code: 1,
data: { planId: 123 }
})
// 提交
const result = await submitPlan()
expect(result.success).toBe(true)
expect(result.data.planId).toBe(123)
expect(submitPlanAPI).toHaveBeenCalledWith({
products: selectedProducts.value
})
})
it('should show loading state during submission', async () => {
const { loading, submitPlan } = usePlanView()
submitPlanAPI.mockImplementation(() => {
return new Promise(resolve => {
loading.value = true
setTimeout(() => {
loading.value = false
resolve({ code: 1, data: {} })
}, 1000)
})
})
const promise = submitPlan()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
})
```
---
## 🎭 E2E 测试(5%)- 关键流程
### 小程序 E2E 测试方案
由于小程序真机测试困难,我们采用 **H5 构建 + Playwright** 的替代方案:
#### 方案对比
| 方案 | 优势 | 劣势 | 推荐指数 |
|------|------|------|---------|
| **微信开发者工具自动化** | 原生支持 | 功能有限,不稳定 | ⭐⭐ |
| **H5 + Playwright** | 成熟稳定,调试方便 | 非原生环境 | ⭐⭐⭐⭐⭐ |
| **真机云测试** | 真实环境 | 成本高,配置复杂 | ⭐⭐⭐ |
#### H5 构建 + Playwright 实施
**1. 创建 E2E 测试文件**
```javascript
// e2e/plan-workflow.spec.js
import { test, expect } from '@playwright/test'
test.describe('计划书提交流程', () => {
test.beforeEach(async ({ page }) => {
// 导航到首页
await page.goto('/#/')
// 登录(如果需要)
const loginButton = page.locator('text=登录')
if (await loginButton.isVisible()) {
await loginButton.click()
// 填写登录信息...
}
})
test('should complete plan submission', async ({ page }) => {
// 1. 进入产品中心
await page.click('text=产品中心')
await expect(page).toHaveURL(/.*product-center/)
// 2. 选择产品
await page.click('[data-product-id="1"]')
await page.click('text=加入计划书')
// 3. 进入计划书页面
await page.click('text=计划书')
await expect(page).toHaveURL(/.*plan/)
// 4. 提交计划书
await page.click('text=提交计划书')
// 验证成功提示
await expect(page.locator('text=提交成功')).toBeVisible()
})
test('should show error when product list is empty', async ({ page }) => {
await page.goto('/#/plan')
// 空列表应该显示提示
await expect(page.locator('text=请先添加产品')).toBeVisible()
})
})
```
**2. Playwright 配置**
```javascript
// playwright.config.js
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: false,
retries: 1,
use: {
baseURL: 'http://localhost:5173/#/',
headless: false,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure'
},
projects: [
{
name: 'chromium',
use: {
viewport: { width: 375, height: 667 }, // 小程序尺寸
locale: 'zh-CN'
}
}
]
})
```
**3. 运行 E2E 测试**
```bash
# 1. 构建 H5 版本
pnpm dev:h5
# 2. 在另一个终端运行 E2E 测试
pnpm test:e2e
# 3. 查看测试报告
# 自动生成在 playwright-report/
```
---
## 📋 实施路线图
### 短期目标(1-2 周)✅
**目标**:完善单元测试覆盖率
- [x] 为所有 Composables 添加测试
- [ ] 为工具函数添加测试
- [ ] 为核心组件添加测试
- [ ] 达到 70% 单元测试覆盖率
**命令**
```bash
# 运行所有测试
pnpm test
# 查看覆盖率报告
pnpm test:coverage
```
### 中期目标(2-4 周)
**目标**:添加集成测试
- [ ] 为关键 API 流程添加集成测试
- [ ] 为完整用户流程添加集成测试
- [ ] 配置 MSW 用于 API Mock
- [ ] 达到 80% 测试覆盖率
**新增依赖**
```bash
pnpm add -D msw
```
### 长期目标(1-2 月)
**目标**:完善 E2E 测试
- [ ] 配置 Playwright
- [ ] 为关键流程编写 E2E 测试
- [ ] 配置 CI/CD 自动测试
- [ ] 达到 90% 测试覆盖率
**新增依赖**
```bash
pnpm add -D @playwright/test
```
---
## ✅ 测试最佳实践
### 1. Mock 策略
**Mock Taro API**
```javascript
vi.mock('@tarojs/taro', () => ({
default: {
navigateTo: vi.fn(),
showModal: vi.fn(),
showToast: vi.fn(),
getSystemInfo: vi.fn(() => Promise.resolve({
model: 'iPhone 12'
}))
}
}))
```
**Mock Pinia Store**
```javascript
import { setActivePinia, createPinia } from 'pinia'
import { createTestingApp } from '@pinia/testing'
const testingApp = createTestingApp({
state: {
user: {
isLoggedIn: true,
userInfo: { name: 'Test User' }
}
}
})
```
### 2. 测试命名规范
**使用描述性命名**
```javascript
// ✅ GOOD
it('should show error message when form is invalid')
// ❌ BAD
it('test 1')
```
### 3. AAA 模式(Arrange-Act-Assert)
```javascript
it('should add product to cart', () => {
// Arrange - 准备测试数据
const product = { id: 1, name: '产品A' }
const cart = []
// Act - 执行操作
const result = addToCart(product, cart)
// Assert - 验证结果
expect(result).toContain(product)
expect(cart.length).toBe(1)
})
```
### 4. 边界测试
**测试边界值和异常情况**
```javascript
describe('boundary testing', () => {
it('should handle empty input')
it('should handle null input')
it('should handle max value')
it('should handle min value')
it('should handle network timeout')
})
```
---
## 🔍 测试检查清单
### 提交代码前
- [ ] 所有新功能有对应的单元测试
- [ ] 测试覆盖率不降低
- [ ] 所有测试通过 (`pnpm test`)
- [ ] 无跳过的测试 (`test.skip()`)
### 发布前
- [ ] E2E 测试通过
- [ ] 关键流程手动测试通过
- [ ] 测试覆盖率 ≥ 80%
- [ ] 性能测试通过
---
## 🛠️ 常用测试命令
```bash
# 运行所有测试
pnpm test
# 运行特定测试文件
pnpm test usePermission.test.js
# 运行匹配的测试
pnpm test --validation
# 监听模式(开发时使用)
pnpm test --watch
# 覆盖率报告
pnpm test:coverage
# UI 模式(交互式调试)
pnpm test --ui
```
---
## 📚 相关文档
- [Vitest 官方文档](https://vitest.dev/)
- [@vue/test-utils 文档](https://test-utils.vuejs.org/)
- [Playwright 文档](https://playwright.dev/)
- [Pinia Testing 文档](https://pinia.vuejs.org/cookbook/testing/)
- [项目 CLAUDE.md](../../CLAUDE.md)
---
**维护说明**
- 本文档随测试基础设施更新而维护
- 有新的测试模式或工具时,及时更新
- 定期审查测试覆盖率,确保测试质量
**最后更新**: 2026-02-22