|
@@ -0,0 +1,478 @@
|
|
|
+# Cypress 登录初始化流程封装
|
|
|
+
|
|
|
+## 1. 目标和价值
|
|
|
+
|
|
|
+### 问题背景
|
|
|
+
|
|
|
+在编写 Cypress e2e 测试时,几乎每个测试用例都需要:
|
|
|
+
|
|
|
+1. 清除存储(session storage、local storage)
|
|
|
+2. Mock 多个必要的 API(i18n、quota、patient types、body parts 等)
|
|
|
+3. 执行登录操作
|
|
|
+4. 等待应用初始化完成
|
|
|
+
|
|
|
+这导致每个测试的 `beforeEach` 都包含大量重复代码(17+ 行),不仅冗长,而且难以维护。
|
|
|
+
|
|
|
+**示例(重复的代码):**
|
|
|
+
|
|
|
+```typescript
|
|
|
+beforeEach(() => {
|
|
|
+ cy.clearAllSessionStorage();
|
|
|
+ cy.clearAllLocalStorage();
|
|
|
+
|
|
|
+ mockAllRequiredAPIs(); // 软件信息、日志
|
|
|
+ mockI18nSuccess('zh'); // 多语言资源
|
|
|
+ mockGetQuotaSuccess(); // 配额检查
|
|
|
+ mockGetPatientTypeHuman(); // 患者类型 - 必需!
|
|
|
+ mockGetBodyPartHuman(); // 身体部位 - 必需!
|
|
|
+ mockLoginSuccess(); // 登录
|
|
|
+
|
|
|
+ loginPage.visit();
|
|
|
+ loginPage.login('admin', '123456');
|
|
|
+ cy.wait('@loginSuccess');
|
|
|
+ cy.contains('登录成功').should('be.visible');
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 解决方案
|
|
|
+
|
|
|
+创建自定义 Cypress 命令 `cy.loginAndInitialize()`,将所有登录初始化逻辑封装成一行代码。
|
|
|
+
|
|
|
+### 带来的好处
|
|
|
+
|
|
|
+1. ✅ **代码复用** - 所有需要登录的测试都可以使用同一个命令
|
|
|
+2. ✅ **易于维护** - 登录流程变更只需修改一处
|
|
|
+3. ✅ **语义清晰** - 命令名称明确表达意图
|
|
|
+4. ✅ **减少错误** - 避免遗漏必要的 Mock 导致测试失败
|
|
|
+5. ✅ **类型安全** - 提供完整的 TypeScript 类型定义
|
|
|
+6. ✅ **代码简洁** - 17 行代码压缩为 1 行
|
|
|
+
|
|
|
+## 2. 实现方案
|
|
|
+
|
|
|
+### 技术选择
|
|
|
+
|
|
|
+使用 Cypress 自定义命令(Custom Commands)机制,通过 `Cypress.Commands.add()` 注册全局命令。
|
|
|
+
|
|
|
+### 实现步骤
|
|
|
+
|
|
|
+#### 步骤 1:创建自定义命令
|
|
|
+
|
|
|
+在 `cypress/support/commands.js` 中添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+Cypress.Commands.add(
|
|
|
+ 'loginAndInitialize',
|
|
|
+ (username = 'admin', password = '123456') => {
|
|
|
+ // 导入所需的 mock handlers
|
|
|
+ const {
|
|
|
+ mockAllRequiredAPIs,
|
|
|
+ mockI18nSuccess,
|
|
|
+ } = require('./mock/handlers/i18n');
|
|
|
+ const { mockGetQuotaSuccess } = require('./mock/handlers/quota');
|
|
|
+ const {
|
|
|
+ mockGetPatientTypeHuman,
|
|
|
+ mockGetBodyPartHuman,
|
|
|
+ } = require('./mock/handlers/protocol');
|
|
|
+ const { mockLoginSuccess } = require('./mock/handlers/user');
|
|
|
+ const LoginPage = require('./pageObjects/LoginPage').default;
|
|
|
+
|
|
|
+ // 清除存储
|
|
|
+ cy.clearAllSessionStorage();
|
|
|
+ cy.clearAllLocalStorage();
|
|
|
+
|
|
|
+ // Mock 所有必要的 API
|
|
|
+ mockAllRequiredAPIs(); // 软件信息、日志
|
|
|
+ mockI18nSuccess('zh'); // 多语言资源
|
|
|
+ mockGetQuotaSuccess(); // 配额检查
|
|
|
+ mockGetPatientTypeHuman(); // 患者类型 - 必需!
|
|
|
+ mockGetBodyPartHuman(); // 身体部位 - 必需!
|
|
|
+ mockLoginSuccess(); // 登录
|
|
|
+
|
|
|
+ // 使用 LoginPage Page Object 进行登录
|
|
|
+ const loginPage = new LoginPage();
|
|
|
+ loginPage.visit();
|
|
|
+ loginPage.login(username, password);
|
|
|
+
|
|
|
+ // 等待登录成功消息
|
|
|
+ cy.wait('@loginSuccess');
|
|
|
+ cy.contains('登录成功', { timeout: 5000 }).should('be.visible');
|
|
|
+
|
|
|
+ // 等待初始化完成 - 退出按钮可见表示已成功进入主页
|
|
|
+ cy.get('[data-testid="exit-button"]', { timeout: 10000 }).should(
|
|
|
+ 'be.visible'
|
|
|
+ );
|
|
|
+
|
|
|
+ cy.log('✅ 登录并初始化完成');
|
|
|
+ }
|
|
|
+);
|
|
|
+```
|
|
|
+
|
|
|
+#### 步骤 2:添加 TypeScript 类型定义
|
|
|
+
|
|
|
+创建 `cypress/support/commands.d.ts`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+/// <reference types="cypress" />
|
|
|
+
|
|
|
+declare namespace Cypress {
|
|
|
+ interface Chainable<Subject = any> {
|
|
|
+ /**
|
|
|
+ * 自定义命令:登录并完成初始化进入主页
|
|
|
+ *
|
|
|
+ * 这个命令封装了完整的登录流程:
|
|
|
+ * 1. 清除存储
|
|
|
+ * 2. Mock 所有必要的 API(i18n、quota、patient types、body parts)
|
|
|
+ * 3. 执行登录
|
|
|
+ * 4. 等待初始化完成
|
|
|
+ *
|
|
|
+ * @param username - 用户名,默认 'admin'
|
|
|
+ * @param password - 密码,默认 '123456'
|
|
|
+ *
|
|
|
+ * @example
|
|
|
+ * cy.loginAndInitialize();
|
|
|
+ * cy.loginAndInitialize('testuser', 'testpass');
|
|
|
+ */
|
|
|
+ loginAndInitialize(
|
|
|
+ username?: string,
|
|
|
+ password?: string
|
|
|
+ ): Chainable<Subject>;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export {};
|
|
|
+```
|
|
|
+
|
|
|
+#### 步骤 3:重构现有测试
|
|
|
+
|
|
|
+**之前(17 行):**
|
|
|
+
|
|
|
+```typescript
|
|
|
+import {
|
|
|
+ mockLoginSuccess,
|
|
|
+ mockLogoutSuccess,
|
|
|
+} from '../../support/mock/handlers/user';
|
|
|
+import {
|
|
|
+ mockI18nSuccess,
|
|
|
+ mockAllRequiredAPIs,
|
|
|
+} from '../../support/mock/handlers/i18n';
|
|
|
+import { mockGetQuotaSuccess } from '../../support/mock/handlers/quota';
|
|
|
+import {
|
|
|
+ mockGetPatientTypeHuman,
|
|
|
+ mockGetBodyPartHuman,
|
|
|
+} from '../../support/mock/handlers/protocol';
|
|
|
+import LoginPage from '../../support/pageObjects/LoginPage';
|
|
|
+
|
|
|
+describe('User Logout', () => {
|
|
|
+ const loginPage = new LoginPage();
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ cy.clearAllSessionStorage();
|
|
|
+ cy.clearAllLocalStorage();
|
|
|
+
|
|
|
+ mockAllRequiredAPIs();
|
|
|
+ mockI18nSuccess('zh');
|
|
|
+ mockGetQuotaSuccess();
|
|
|
+ mockGetPatientTypeHuman();
|
|
|
+ mockGetBodyPartHuman();
|
|
|
+ mockLoginSuccess();
|
|
|
+
|
|
|
+ loginPage.visit();
|
|
|
+ loginPage.login('admin', '123456');
|
|
|
+ cy.wait('@loginSuccess');
|
|
|
+ cy.contains('登录成功').should('be.visible');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should logout successfully', () => {
|
|
|
+ // 测试代码
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+**之后(1 行):**
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { mockLogoutSuccess } from '../../support/mock/handlers/user';
|
|
|
+import LoginPage from '../../support/pageObjects/LoginPage';
|
|
|
+
|
|
|
+describe('User Logout', () => {
|
|
|
+ const loginPage = new LoginPage();
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ cy.loginAndInitialize();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should logout successfully', () => {
|
|
|
+ // 测试代码
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+## 3. 使用示例
|
|
|
+
|
|
|
+### 示例 1:基本用法(使用默认用户名密码)
|
|
|
+
|
|
|
+```typescript
|
|
|
+describe('Main Page Tests', () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ // 使用默认用户名 'admin' 和密码 '123456'
|
|
|
+ cy.loginAndInitialize();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should display main page correctly', () => {
|
|
|
+ // 现在已经在主页了,可以直接开始测试
|
|
|
+ cy.get('[data-testid="exit-button"]').should('be.visible');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 示例 2:使用自定义用户名和密码
|
|
|
+
|
|
|
+```typescript
|
|
|
+describe('Different User Tests', () => {
|
|
|
+ beforeEach(() => {
|
|
|
+ // 使用自定义用户名和密码
|
|
|
+ cy.loginAndInitialize('testuser', 'testpass123');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should work with different user', () => {
|
|
|
+ // 测试代码
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 示例 3:在单个测试中使用
|
|
|
+
|
|
|
+```typescript
|
|
|
+describe('Specific Test', () => {
|
|
|
+ it('should handle logout correctly', () => {
|
|
|
+ // 在测试内部登录
|
|
|
+ cy.loginAndInitialize();
|
|
|
+
|
|
|
+ // 执行测试操作
|
|
|
+ cy.get('[data-testid="exit-button"]').click();
|
|
|
+ cy.get('[data-testid="exit-modal-logout-button"]').click();
|
|
|
+
|
|
|
+ // 验证返回登录页
|
|
|
+ cy.get('[data-testid="login-username-input"]').should('be.visible');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 示例 4:结合 Page Objects 使用
|
|
|
+
|
|
|
+```typescript
|
|
|
+import MainPage from '../../support/pageObjects/MainPage';
|
|
|
+import ExitModalPage from '../../support/pageObjects/ExitModalPage';
|
|
|
+
|
|
|
+describe('Logout Flow', () => {
|
|
|
+ const mainPage = new MainPage();
|
|
|
+ const exitModalPage = new ExitModalPage();
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ cy.loginAndInitialize();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should successfully logout', () => {
|
|
|
+ // 使用 Page Object 执行操作
|
|
|
+ mainPage.clickExitButton();
|
|
|
+ exitModalPage.shouldBeVisible();
|
|
|
+ exitModalPage.clickLogout();
|
|
|
+
|
|
|
+ // 验证登出成功
|
|
|
+ cy.contains('已退出登录').should('be.visible');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 示例 5:多次登录场景
|
|
|
+
|
|
|
+```typescript
|
|
|
+describe('Multiple Login Tests', () => {
|
|
|
+ it('should support multiple logins in same test', () => {
|
|
|
+ // 第一次登录
|
|
|
+ cy.loginAndInitialize('user1', 'pass1');
|
|
|
+ cy.get('[data-testid="exit-button"]').should('be.visible');
|
|
|
+
|
|
|
+ // 登出
|
|
|
+ cy.get('[data-testid="exit-button"]').click();
|
|
|
+ cy.get('[data-testid="exit-modal-logout-button"]').click();
|
|
|
+
|
|
|
+ // 第二次登录
|
|
|
+ cy.loginAndInitialize('user2', 'pass2');
|
|
|
+ cy.get('[data-testid="exit-button"]').should('be.visible');
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+## 4. 技术细节
|
|
|
+
|
|
|
+### 封装的内容
|
|
|
+
|
|
|
+`cy.loginAndInitialize()` 命令封装了以下操作:
|
|
|
+
|
|
|
+1. **清除存储**
|
|
|
+
|
|
|
+ ```javascript
|
|
|
+ cy.clearAllSessionStorage();
|
|
|
+ cy.clearAllLocalStorage();
|
|
|
+ ```
|
|
|
+
|
|
|
+2. **Mock 所有必要的 API**
|
|
|
+
|
|
|
+ - `mockAllRequiredAPIs()` - 软件信息、日志 API
|
|
|
+ - `mockI18nSuccess('zh')` - 多语言资源
|
|
|
+ - `mockGetQuotaSuccess()` - 配额检查
|
|
|
+ - `mockGetPatientTypeHuman()` - 患者类型(AppInitializer 必需)
|
|
|
+ - `mockGetBodyPartHuman()` - 身体部位(AppInitializer 必需)
|
|
|
+ - `mockLoginSuccess()` - 登录 API
|
|
|
+
|
|
|
+3. **执行登录**
|
|
|
+
|
|
|
+ ```javascript
|
|
|
+ loginPage.visit();
|
|
|
+ loginPage.login(username, password);
|
|
|
+ ```
|
|
|
+
|
|
|
+4. **等待登录成功**
|
|
|
+
|
|
|
+ ```javascript
|
|
|
+ cy.wait('@loginSuccess');
|
|
|
+ cy.contains('登录成功', { timeout: 5000 }).should('be.visible');
|
|
|
+ ```
|
|
|
+
|
|
|
+5. **等待初始化完成**
|
|
|
+ ```javascript
|
|
|
+ cy.get('[data-testid="exit-button"]', { timeout: 10000 }).should(
|
|
|
+ 'be.visible'
|
|
|
+ );
|
|
|
+ ```
|
|
|
+
|
|
|
+### TypeScript 支持
|
|
|
+
|
|
|
+TypeScript 类型定义提供:
|
|
|
+
|
|
|
+- ✅ 智能提示(IntelliSense)
|
|
|
+- ✅ 参数类型检查
|
|
|
+- ✅ 返回值类型推断
|
|
|
+- ✅ JSDoc 文档提示
|
|
|
+
|
|
|
+### 为什么需要 Mock Patient Types 和 Body Parts?
|
|
|
+
|
|
|
+应用的 `AppInitializer` 组件在登录后会执行初始化操作:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// AppInitializer 初始化流程
|
|
|
+1. getPatientTypes() // 获取患者类型列表
|
|
|
+2. getBodyParts() // 获取身体部位列表
|
|
|
+3. initializeProductState() // 初始化产品状态
|
|
|
+```
|
|
|
+
|
|
|
+只有这三个操作**全部完成**后,才会调用 `onInitialized()` 进入主界面。
|
|
|
+
|
|
|
+如果缺少相应的 Mock:
|
|
|
+
|
|
|
+- ❌ 初始化会卡住
|
|
|
+- ❌ 显示"正在初始化,请稍候..."
|
|
|
+- ❌ 测试会超时失败
|
|
|
+
|
|
|
+### 超时设置
|
|
|
+
|
|
|
+- **登录成功消息**:5 秒超时
|
|
|
+- **初始化完成**:10 秒超时(等待退出按钮可见)
|
|
|
+
|
|
|
+这些超时值经过测试验证,足够应对正常的初始化流程。
|
|
|
+
|
|
|
+### 注意事项
|
|
|
+
|
|
|
+1. **命令必须在 beforeEach 或测试内部调用**
|
|
|
+
|
|
|
+ ```typescript
|
|
|
+ // ✅ 正确
|
|
|
+ beforeEach(() => {
|
|
|
+ cy.loginAndInitialize();
|
|
|
+ });
|
|
|
+
|
|
|
+ // ❌ 错误 - 不要在 describe 块外调用
|
|
|
+ cy.loginAndInitialize();
|
|
|
+ describe('Tests', () => {});
|
|
|
+ ```
|
|
|
+
|
|
|
+2. **确保 Mock Handlers 已正确导入**
|
|
|
+
|
|
|
+ - 命令内部会自动导入所需的 Mock Handlers
|
|
|
+ - 不需要在测试文件中手动导入
|
|
|
+
|
|
|
+3. **Page Objects 的使用**
|
|
|
+
|
|
|
+ - 命令内部使用 `LoginPage` Page Object
|
|
|
+ - 确保 `LoginPage` 实现正确
|
|
|
+
|
|
|
+4. **环境变量**
|
|
|
+ - 命令使用当前环境的配置
|
|
|
+ - 确保测试环境配置正确
|
|
|
+
|
|
|
+## 5. 维护指南
|
|
|
+
|
|
|
+### 如果登录流程发生变化
|
|
|
+
|
|
|
+只需修改 `cypress/support/commands.js` 中的 `loginAndInitialize` 命令,所有使用该命令的测试会自动更新。
|
|
|
+
|
|
|
+**示例:添加新的 Mock**
|
|
|
+
|
|
|
+```javascript
|
|
|
+Cypress.Commands.add(
|
|
|
+ 'loginAndInitialize',
|
|
|
+ (username = 'admin', password = '123456') => {
|
|
|
+ // ... 现有代码 ...
|
|
|
+
|
|
|
+ // 添加新的 Mock
|
|
|
+ mockNewRequiredAPI();
|
|
|
+
|
|
|
+ // ... 现有代码 ...
|
|
|
+ }
|
|
|
+);
|
|
|
+```
|
|
|
+
|
|
|
+### 如果需要不同的初始化流程
|
|
|
+
|
|
|
+可以创建变体命令:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 快速登录(跳过某些等待)
|
|
|
+Cypress.Commands.add('loginQuick', (username, password) => {
|
|
|
+ // 简化的登录流程
|
|
|
+});
|
|
|
+
|
|
|
+// 登录到特定页面
|
|
|
+Cypress.Commands.add('loginToPage', (username, password, pagePath) => {
|
|
|
+ cy.loginAndInitialize(username, password);
|
|
|
+ cy.visit(pagePath);
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+## 6. 总结
|
|
|
+
|
|
|
+### 封装前后对比
|
|
|
+
|
|
|
+| 方面 | 封装前 | 封装后 |
|
|
|
+| -------- | -------------------- | ---------------- |
|
|
|
+| 代码行数 | 17+ 行 | 1 行 |
|
|
|
+| 导入依赖 | 5+ 个 import | 不需要 |
|
|
|
+| 可维护性 | 低(每个测试都要改) | 高(只改一处) |
|
|
|
+| 易用性 | 复杂 | 简单 |
|
|
|
+| 类型安全 | 无 | 有(TypeScript) |
|
|
|
+| 错误风险 | 高(容易遗漏 Mock) | 低(一次性配置) |
|
|
|
+
|
|
|
+### 适用场景
|
|
|
+
|
|
|
+- ✅ 所有需要登录的 e2e 测试
|
|
|
+- ✅ 需要进入主页的测试
|
|
|
+- ✅ 需要完整初始化流程的测试
|
|
|
+
|
|
|
+### 不适用场景
|
|
|
+
|
|
|
+- ❌ 测试登录页面本身
|
|
|
+- ❌ 测试登录失败场景
|
|
|
+- ❌ 需要特殊 Mock 配置的场景
|
|
|
+
|
|
|
+对于特殊场景,可以直接手动编写登录逻辑,或创建专门的变体命令。
|