docs(e2e): 添加 E2E 测试设计规格文档

流程链路式双端 E2E 测试体系设计:
- Web 端 5 条业务链路(Playwright + Page Object)
- 小程序端 4 条业务链路(Vitest + miniprogram-automator)
- API 驱动自建自毁数据策略,乐观锁 version 支持
- CI-ready 环境变量驱动设计
This commit is contained in:
iven
2026-04-28 21:57:19 +08:00
parent 5ab8bf8479
commit 4eb874f52d

View File

@@ -0,0 +1,603 @@
# E2E 测试设计规格 — HMS 健康管理平台
> 日期: 2026-04-28 | 状态: 设计中
## Context
HMS 健康管理平台当前 E2E 测试仅有 10 个基础用例(登录/用户/插件/租户隔离),健康模块 ~27 个 Web 页面路由和微信小程序端完全无 E2E 覆盖。wiki 已将此标注为 P2 缺口。本设计旨在建立双端Web + 小程序)的流程链路式 E2E 测试体系。
## 大纲
1. 整体架构与目录结构
2. 关键业务链路定义
3. 测试数据策略与 API Client
4. Page Object 与 MCP Helper 设计
5. 执行策略与 CI-Ready 设计
---
## §1 整体架构与目录结构
### 设计决策
| 维度 | 选择 | 原因 |
|------|------|------|
| 覆盖范围 | Web 全量 + 小程序并行 | 双端同步覆盖,最大化业务保障 |
| 测试深度 | 关键流程冒烟测试 | 务实覆盖最重要的用户路径 |
| 小程序实现 | WeChat MCP 自动化 | 利用已有 MCP 联调能力 |
| 测试架构 | 双端独立框架 | Web 用 Playwright小程序用 Vitest + MCP |
| 测试数据 | API 驱动自建自毁 | 每个测试独立创建/清理数据 |
| CI 策略 | 本地优先CI-ready 设计 | 环境变量驱动,未来可接入 GitHub Actions |
### 目录结构
```
apps/web/e2e/ # Web 端 E2EPlaywright
├── flows/ # 业务链路测试
│ ├── patient-journey.spec.ts # 患者全流程
│ ├── vital-signs-flow.spec.ts # 体征数据链路
│ ├── appointment-flow.spec.ts # 预约排班链路
│ ├── follow-up-flow.spec.ts # 随访管理链路
│ └── alert-flow.spec.ts # 告警处理链路
├── smoke/ # 基础冒烟(迁移现有 4 个 spec
│ ├── login.spec.ts
│ ├── users.spec.ts
│ ├── plugins.spec.ts
│ └── tenant-isolation.spec.ts
├── fixtures/ # 测试基础设施
│ ├── auth.fixture.ts # 认证 fixture增强现有
│ ├── api-client.ts # API 数据准备/清理客户端
│ └── test-data.ts # 测试数据工厂函数
├── pages/ # 轻量 Page Object仅关键页面
│ ├── login.page.ts
│ ├── patient-list.page.ts
│ ├── patient-detail.page.ts
│ ├── appointment.page.ts
│ └── health-data.page.ts
└── playwright.config.ts # 增强配置
apps/miniprogram/e2e/ # 小程序端 E2EMCP 自动化)
├── flows/ # 业务链路测试
│ ├── patient-health-view.spec.ts # 患者查看健康数据
│ ├── vital-signs-input.spec.ts # 体征录入
│ ├── points-flow.spec.ts # 积分签到兑换
│ └── mall-flow.spec.ts # 积分商城
├── helpers/ # MCP 封装
│ ├── mcp-client.ts # MCP 工具调用封装
│ ├── auth.helper.ts # 小程序登录/Token 注入
│ └── navigation.helper.ts # 页面导航封装
├── check-readiness.ts # 环境就绪检查
└── vitest.config.ts # 测试运行器配置
```
### 关键原则
1. 现有 4 个 smoke test 原地迁移到 `smoke/` 目录,逻辑不变
2. Web 端保持 Playwright小程序端用 Vitest + `miniprogram-automator`,各自独立运行
3. 两端 API client 逻辑保持一致但代码各自维护Web 运行在 Node小程序也运行在 Node可考虑后续抽取共享包
4. Page Object 只封装关键页面,随访/告警等简单页面直接在 spec 中操作
5. 每条链路自包含API 准备所需数据),不依赖其他链路的执行顺序
6. 所有 Page Object 使用 Hash Router 路径格式(`/#/health/patients`
## §2 关键业务链路定义
### Web 端链路5 条)
**Flow 1: 患者全流程 `patient-journey.spec.ts`**
```
登录 → 患者列表页 → 创建患者 → 查看患者详情 → 编辑患者信息
→ 分配医生 → 添加家族成员 → 删除测试数据
```
验证点:患者 CRUD、详情页数据完整性、医生关联、家族成员管理
**Flow 2: 体征数据链路 `vital-signs-flow.spec.ts`**
```
登录 → 选择患者 → 录入体征数据(血压/心率/血糖)
→ 查看体征列表 → 查看趋势图 → 录入化验报告 → 审核报告
→ 清理数据
```
验证点:体征录入表单、列表展示、趋势图表渲染、化验报告上传与审核
**Flow 3: 预约排班链路 `appointment-flow.spec.ts`**
```
登录 → 创建医生 → 设置排班 → 患者列表 → 创建预约
→ 查看预约列表 → 更新预约状态 → 清理数据
```
验证点:排班日历交互、预约创建、状态流转(待确认→已确认→已完成)
**Flow 4: 随访管理链路 `follow-up-flow.spec.ts`**
```
登录 → 创建随访模板 → 批量创建随访任务 → 查看任务列表
→ 完成随访(填写记录)→ 查看随访记录 → 清理数据
```
验证点:模板管理、批量操作、任务分配、随访记录填写
**Flow 5: 告警处理链路 `alert-flow.spec.ts`**
```
登录(API) → 创建告警规则(低阈值,如心率 > 50
→ 录入触发体征数据API 创建心率为 110 的体征)→ 轮询等待告警生成(最多 10s
→ 查看告警列表UI→ 确认告警 → 处理告警 → 查看告警规则
→ 停用规则 → 清理数据(规则 + 体征 + 告警)
```
验证点:告警规则配置、告警自动生成、列表/仪表盘展示、状态流转(待处理→已确认→已处理)
**异步处理说明:** 告警由后台任务根据规则匹配体征数据自动生成。测试通过 API 创建低阈值规则 + 触发数据,然后轮询告警列表 API 等待告警出现(`await expect.poll(() => api.getAlerts(), { timeout: 10_000 })`)。
### 小程序端链路4 条)
**Flow 6: 患者健康数据查看 `patient-health-view.spec.ts`**
```
MCP 登录 → 首页 → 查看今日体征摘要 → 查看体征趋势
→ 查看随访任务列表 → 查看化验报告
```
**Flow 7: 体征数据录入 `vital-signs-input.spec.ts`**
```
MCP 登录 → 进入录入页 → 填写血压/心率 → 提交
→ 验证数据出现在列表 → 清理数据
```
**Flow 8: 积分签到兑换 `points-flow.spec.ts`**
```
MCP 登录 → 每日签到 → 查看积分余额 → 浏览商品
→ 兑换商品 → 查看订单列表
```
**Flow 9: 积分商城 `mall-flow.spec.ts`**
```
MCP 登录 → 商城首页 → 浏览商品分类 → 查看商品详情
→ 查看线下活动 → 活动报名
```
### 链路覆盖矩阵
| 业务模块 | Web 链路 | 小程序链路 | API 链路(已有) |
|---------|---------|-----------|---------------|
| 患者管理 | Flow 1 | — | Chain 1 |
| 体征/健康数据 | Flow 2 | Flow 6, 7 | Chain 1, 2 |
| 预约排班 | Flow 3 | — | Chain 1 |
| 随访管理 | Flow 4 | Flow 6 | Chain 1 |
| 告警系统 | Flow 5 | — | — |
| 积分体系 | — | Flow 8 | Chain 4 |
| 积分商城 | — | Flow 9 | Chain 4 |
未覆盖模块说明:
- **咨询管理**2 页面)— 有完整 API 链路,但实时消息交互难以用 E2E 验证,建议 API 集成测试覆盖
- **内容/文章管理**4 页面)— CRUD 操作较标准,优先级低于核心医疗流程
- **AI 分析**3 页面)— 依赖外部 AI 服务,不稳定,建议 mock 测试
- **统计仪表盘**5 页面)— 数据聚合展示E2E 价值有限,建议视觉回归测试
## §3 测试数据策略与 API Client
### 核心原则
API 驱动自建自毁:每个测试通过 API 创建所需数据,测试结束后清理。不依赖数据库预置状态。
### API Client
后端所有实体使用乐观锁(`version` 字段),更新/删除操作必须携带 version。API Client 的所有方法返回完整实体(含 version清理操作使用返回的 version。
```typescript
// fixtures/api-client.ts
interface VersionedEntity { id: string; version: number }
class ApiClient {
private token: string;
private baseUrl: string;
// 认证
async login(username: string, password: string): Promise<AuthResponse>
async loginAsAdmin(): Promise<AuthResponse>
// 患者数据工厂 — 返回含 version 的完整实体
async createPatient(overrides?: Partial<PatientData>): Promise<Patient & VersionedEntity>
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<Patient & VersionedEntity>
async deletePatient(id: string, version: number): Promise<void>
// 医生数据工厂
async createDoctor(overrides?: Partial<DoctorData>): Promise<Doctor & VersionedEntity>
async deleteDoctor(id: string, version: number): Promise<void>
// 体征数据工厂
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VitalSigns & VersionedEntity>
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void>
// 预约数据工厂
async createAppointment(overrides?: Partial<AppointmentData>): Promise<Appointment & VersionedEntity>
async updateAppointmentStatus(id: string, version: number, status: string): Promise<Appointment & VersionedEntity>
async deleteAppointment(id: string, version: number): Promise<void>
// 随访数据工厂
async createFollowUpTemplate(overrides?: Partial<TemplateData>): Promise<Template & VersionedEntity>
async createFollowUpTask(overrides?: Partial<TaskData>): Promise<Task & VersionedEntity>
async deleteFollowUpTemplate(id: string, version: number): Promise<void>
async deleteFollowUpTask(id: string, version: number): Promise<void>
// 告警数据工厂
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<AlertRule & VersionedEntity>
async deleteAlertRule(id: string, version: number): Promise<void>
// 告警状态流转(需要 version
async acknowledgeAlert(id: string, version: number): Promise<Alert & VersionedEntity>
async resolveAlert(id: string, version: number): Promise<Alert & VersionedEntity>
async dismissAlert(id: string, version: number): Promise<Alert & VersionedEntity>
// 排班数据工厂
async createSchedule(overrides?: Partial<ScheduleData>): Promise<Schedule & VersionedEntity>
async deleteSchedule(id: string, version: number): Promise<void>
// 通用 HTTP
async get<T>(path: string): Promise<T>
async post<T>(path: string, body: unknown): Promise<T>
async put<T>(path: string, body: unknown): Promise<T>
async delete(path: string, body?: unknown): Promise<void>
}
```
### 测试数据工厂
```typescript
// fixtures/test-data.ts
function makePatient(overrides?: Partial<PatientData>): PatientData {
const id = `test_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
return {
name: `测试患者_${id}`,
gender: 'male',
birthDate: '1990-01-15',
phone: `138${Math.random().toString().slice(2, 11)}`,
idNumber: `110101199001${Math.random().toString().slice(2, 8)}`,
...overrides,
};
}
function makeDoctor(overrides?: Partial<DoctorData>): DoctorData { ... }
function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData { ... }
function makeAppointment(patientId: string, doctorId: string, scheduleId: string): AppointmentData { ... }
```
每次生成唯一数据(时间戳 + 随机串),避免测试间冲突。
### 数据生命周期管理
所有删除/更新操作必须携带 version乐观锁。创建操作的返回值包含 version清理时使用。
**Web 端Playwright— try/finally 模式:**
```typescript
test('患者全流程', async ({ page, api }) => {
const cleanup: Array<() => Promise<void>> = [];
try {
const patient = await api.createPatient();
// 创建时注册清理函数,保存 id + version
cleanup.push(() => api.deletePatient(patient.id, patient.version));
const doctor = await api.createDoctor();
cleanup.push(() => api.deleteDoctor(doctor.id, doctor.version));
// ... 测试操作(更新时使用最新 version...
} finally {
// 逆序清理(后创建的先删除,避免外键约束问题)
for (const fn of cleanup.reverse()) {
await fn().catch(() => {}); // 忽略已删除的错误
}
}
});
```
**小程序端Vitest— afterEach 模式:**
```typescript
describe('体征数据录入', () => {
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const fn of cleanup.reverse()) await fn().catch(() => {});
cleanup.length = 0;
});
it('录入血压数据', async () => {
const vitalSigns = await api.createVitalSigns(patientId);
cleanup.push(() => api.deleteVitalSigns(patientId, vitalSigns.id, vitalSigns.version));
// ... 断言 ...
});
});
```
**Fixture 组合Web Playwright** 将认证 page 和 ApiClient 注入到同一个 test fixture
```typescript
// fixtures/auth.fixture.ts — 增强版
import { test as base } from '@playwright/test';
import { ApiClient } from './api-client';
type E2eFixtures = {
api: ApiClient;
authenticatedPage: Page;
};
export const test = base.extend<E2eFixtures>({
// 注入已认证的 ApiClient
api: async ({}, use) => {
const client = new ApiClient();
await client.loginAsAdmin();
await use(client);
},
// 注入已认证的 page继承现有 auth.fixture 逻辑)
authenticatedPage: async ({ page }, use) => {
const client = new ApiClient();
const { access_token, refresh_token, user } = await client.loginAsAdmin();
await page.addInitScript(({ token, refresh, userData }) => {
localStorage.setItem('access_token', token);
localStorage.setItem('refresh_token', refresh);
localStorage.setItem('user', userData);
}, { token: access_token, refresh: refresh_token, userData: JSON.stringify(user) });
await use(page);
},
});
```
### 认证策略
| 端 | 认证方式 | 账号 |
|---|---------|------|
| Webadmin | API login → localStorage 注入 | `admin` / `Admin@2026` |
| Web受限角色 | 同上,不同账号 | 用于权限验证场景 |
| 小程序 | MCP ensureConnection → storage 注入 token | `mp_e2e_test` / `Test@2026` |
凭证从环境变量读取fallback 到开发默认值:
```typescript
const TEST_ADMIN = {
username: process.env.E2E_ADMIN_USER || 'admin',
password: process.env.E2E_ADMIN_PASS || 'Admin@2026',
};
```
## §4 Page Object 与 MCP Helper 设计
### Web 端 Page Object5 个,轻量级)
每个 Page Object 包含 3 部分:**导航**、**操作**、**断言辅助**。只封装关键页面,随访/告警等简单页面直接在 spec 中操作。
**Login.page.ts**
```typescript
class LoginPage {
async goto(): Promise<void>
async login(username: string, password: string): Promise<void>
async getErrorMessage(): Promise<string>
async isLoggedIn(): Promise<boolean>
}
```
**PatientList.page.ts**
```typescript
class PatientListPage {
async goto(): Promise<void> // 导航 + 等待表格加载
async clickCreate(): Promise<void>
async fillCreateForm(data: PatientData): Promise<void>
async submitCreateForm(): Promise<void>
async searchPatient(name: string): Promise<void>
async clickPatientRow(index: number): Promise<void>
async getTableRowCount(): Promise<number>
async hasPatientInTable(name: string): Promise<boolean>
}
```
**PatientDetail.page.ts**
```typescript
class PatientDetailPage {
async goto(id: string): Promise<void>
async getPatientName(): Promise<string>
async clickTab(tabName: string): Promise<void> // 体征/随访/化验/诊断
async getVitalSignsCount(): Promise<number>
async assignDoctor(doctorName: string): Promise<void>
async addFamilyMember(data: FamilyMemberData): Promise<void>
}
```
**HealthData.page.ts**
```typescript
class HealthDataPage {
async clickAddVitalSigns(): Promise<void>
async fillVitalSignsForm(data: VitalSignsData): Promise<void>
async submitVitalSigns(): Promise<void>
async getVitalSignsList(): Promise<VitalSignsData[]>
async trendChartIsVisible(): Promise<boolean>
async uploadLabReport(filePath: string): Promise<void>
async reviewLabReport(reportId: string): Promise<void>
}
```
**Appointment.page.ts**
```typescript
class AppointmentPage {
async gotoSchedule(): Promise<void>
async gotoAppointments(): Promise<void>
async selectDateOnCalendar(date: string): Promise<void>
async createScheduleSlot(data: ScheduleData): Promise<void>
async createAppointment(data: AppointmentData): Promise<void>
async updateStatus(appointmentId: string, status: string): Promise<void>
}
```
### 小程序端 Automator Helper3 个)
小程序使用 `miniprogram-automator` npm 包(项目已有依赖,见 `apps/miniprogram/test-automator.mjs`
通过 WeChat DevTools 的自动化接口操作小程序。与 MCP 工具相同的底层能力,但可独立运行在 Vitest 中。
**前置条件:** 需要先构建小程序(`pnpm build:weapp`WeChat DevTools 需打开项目并启用自动化端口。
**AutomatorClient** 封装 miniprogram-automator 为 TypeScript API
```typescript
import automator from 'miniprogram-automator';
class AutomatorClient {
private mini: automator.MiniProgram;
// 连接/启动小程序
async connect(cliPath?: string, projectPath?: string): Promise<void>
async disconnect(): Promise<void>
// 页面操作
async currentPage(): Promise<automator.Page>
async navigateTo(path: string, query?: Record<string, string>): Promise<void>
async navigateBack(): Promise<void>
async reLaunch(path: string): Promise<void>
// 元素操作
async waitForElement(selector: string, timeout?: number): Promise<automator.Element>
async tap(selector: string): Promise<void>
async inputText(selector: string, value: string): Promise<void>
async getElement(selector: string): Promise<automator.Element>
async getElements(selector: string): Promise<automator.Element[]>
// 页面数据
async getPageData(path?: string): Promise<any>
async screenshot(path?: string): Promise<Buffer>
}
```
**MpAuthHelper** 小程序认证
```typescript
class MpAuthHelper {
constructor(private client: AutomatorClient, private api: ApiClient) {}
// API 获取 token → 通过 automator 写入 storage → reLaunch 刷新
async loginAsTestPatient(): Promise<void>
}
```
**MpNavigator** 小程序页面导航封装
```typescript
class MpNavigator {
constructor(private client: AutomatorClient) {}
async goToHealthHome(): Promise<void> // reLaunch /pages/pkg-health/index
async goToVitalSignsInput(): Promise<void> // navigateTo 输入页
async goToVitalSignsTrend(): Promise<void> // navigateTo 趋势页
async goToPointsCheckin(): Promise<void> // navigateTo 签到页
async goToMall(): Promise<void> // reLaunch 商城首页
async goToFollowUpTasks(): Promise<void> // navigateTo 随访列表
async goToOrders(): Promise<void> // navigateTo 订单列表
}
```
**小程序页面路径参考(来自 app.config.ts**
- 患者首页: `/pages/pkg-health/index`
- 体征录入: `/pages/pkg-health/input/index`
- 体征趋势: `/pages/pkg-health/trend/index`
- 积分签到: `/pages/pkg-profile/index`(签到入口在个人中心)
- 积分商城: `/pages/pkg-mall/index`
- 随访任务: `/pages/pkg-health/followups/index`
- 订单列表: `/pages/pkg-mall/orders/index`
### 层级关系
```
┌─────────────────────────────────────────┐
│ Flow Spec │ ← 业务链路测试用例
├──────────────┬──────────────────────────┤
│ Page Object │ MCP Helper │ ← UI 操作抽象
│ (Web 端) │ (小程序端) │
├──────────────┴──────────────────────────┤
│ API Client │ ← 数据准备/清理
├─────────────────────────────────────────┤
│ Test Data Factory │ ← 随机数据生成
├─────────────────────────────────────────┤
│ Auth Fixture / Auth Helper │ ← 认证
└─────────────────────────────────────────┘
```
## §5 执行策略与 CI-Ready 设计
### 本地执行命令
```bash
# Web 端 E2E
cd apps/web
pnpm test:e2e # 运行全部smoke + flows
pnpm test:e2e -- --grep @smoke # 只跑冒烟
pnpm test:e2e -- --grep "患者全流程" # 指定链路
pnpm test:e2e:ui # 交互式 UI 模式
# 小程序端 E2E
cd apps/miniprogram
pnpm test:e2e # 运行全部
pnpm test:e2e -- --reporter=verbose # 详细输出
```
### Playwright Config 增强
关键变更:
- `testDir: './e2e'`保持不变smoke/ 和 flows/ 子目录都在 e2e 下)
- `testMatch: ['smoke/**/*.spec.ts', 'flows/**/*.spec.ts']`
- `timeout: 60_000`(链路测试需要更长时间)
- `fullyParallel: false`(链路间有数据依赖,串行更稳定)
- `reporter: [['html'], ['list']]`
- `globalSetup: './e2e/check-readiness'`
- 标签过滤:`@smoke` 快速验证,`@flow` 完整链路
### 小程序 Vitest Config
**新增依赖:** `apps/miniprogram/package.json` 需添加:
- `vitest`(测试运行器)
- `test:e2e` script
- `miniprogram-automator` 已在项目中(`test-automator.mjs` 使用)
配置:
- `testTimeout: 30_000`
- `testSequence: 'sequential'`automator 操作必须串行)
- `globalSetup: check-readiness`
- 测试前需执行 `pnpm build:weapp` 构建小程序
- WeChat DevTools 必须打开项目并启用自动化端口
### 前置条件检查
测试运行前自动验证环境就绪。Web 端检查 `http://localhost:3000/health/live` + `http://localhost:5174`。小程序端额外检查 WeChat DevTools 自动化端口可达。最多重试 5 次,间隔 2s任一失败则打印明确提示并退出。
### CI-Ready 设计
本地优先,但设计上保证 CI 兼容:
**环境变量驱动:**
```bash
E2E_BASE_URL=http://localhost:5174
E2E_API_URL=http://localhost:3000
E2E_ADMIN_USER=admin
E2E_ADMIN_PASS=Admin@2026
E2E_MP_USER=mp_e2e_test
E2E_MP_PASS=Test@2026
E2E_HEADLESS=true
E2E_SKIP_MP=false # CI 中可跳过小程序测试
```
**GitHub Actions 预留:**
```yaml
# .github/workflows/e2e.yml — 未来启用
name: E2E Tests
jobs:
web-e2e:
services:
postgres: { ... }
steps:
- run: cargo run -p erp-server &
- run: cd apps/web && pnpm test:e2e
```
**测试结果输出:**
- Playwright HTML 报告 → `apps/web/e2e-results/`
- Vitest JUnit 报告 → `apps/miniprogram/e2e-results/`
### 执行耗时预估
| 场景 | 命令 | 预计耗时 |
|-----|------|---------|
| Web 全量 | `pnpm test:e2e` | ~3-5 分钟 |
| Web 冒烟 | `--grep @smoke` | ~30 秒 |
| Web 单条链路 | `--grep "患者"` | ~30-60 秒 |
| 小程序全量 | `pnpm test:e2e` | ~2-4 分钟 |
| 双端全量 | 根目录脚本串联 | ~6-10 分钟 |
---
## 验证方式
1. `cd apps/web && pnpm test:e2e` — Web 端全部通过
2. `cd apps/miniprogram && pnpm test:e2e` — 小程序端全部通过
3. 确认测试数据已清理(数据库无残留测试记录)
4. `--grep @smoke` 可在 30 秒内完成冒烟验证
5. HTML 报告可正常打开查看