Files
hms/docs/superpowers/specs/2026-04-28-e2e-testing-design.md
iven 4eb874f52d docs(e2e): 添加 E2E 测试设计规格文档
流程链路式双端 E2E 测试体系设计:
- Web 端 5 条业务链路(Playwright + Page Object)
- 小程序端 4 条业务链路(Vitest + miniprogram-automator)
- API 驱动自建自毁数据策略,乐观锁 version 支持
- CI-ready 环境变量驱动设计
2026-04-28 21:57:19 +08:00

604 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 报告可正常打开查看