# E2E 测试规范 ## 版本信息 | 版本 | 日期 | 描述 | 作者 | |------|------|------|------| | 1.0 | 2026-01-03 | 创建E2E测试规范文档 | James (Claude Code) | ## 概述 本文档定义了端到端(E2E)测试的标准和最佳实践,用于验证完整的用户流程和系统功能。 ### 适用范围 - **Web应用**: `web/tests/e2e/` - 基于Playwright的Web应用E2E测试 - **小程序应用**: `mini/tests/e2e/` - 基于Taro/DOT的小程序E2E测试(如需要) ## 测试框架栈 ### Web应用E2E测试 - **Playwright**: E2E测试框架 - **支持浏览器**: Chromium、Firefox、WebKit、Mobile Chrome、Mobile Safari ## 测试文件组织规范 ### 目录结构 ``` web/tests/e2e/ ├── playwright.config.ts # Playwright配置 (testDir: '.') ├── global-setup.ts # 全局测试前置设置 ├── global-teardown.ts # 全局测试后置清理 ├── fixtures/ # 测试数据fixtures │ ├── test-users.json # 测试用户数据 │ └── test-data.ts # 测试数据工厂 ├── pages/ # Page Object Model │ └── admin/ # 管理后台页面对象 │ ├── login.page.ts │ └── dashboard.page.ts ├── specs/ # 页面级E2E测试 (按功能分组) │ └── admin/ │ ├── login.spec.ts │ ├── dashboard.spec.ts │ └── users.spec.ts ├── *.spec.ts # API兼容性测试等 (放根目录) └── utils/ # 测试工具函数 └── test-setup.ts ``` ### 文件放置规范 | 文件类型 | 放置位置 | 命名规范 | |----------|----------|----------| | 页面级E2E测试 | `specs/` 子目录 | `<功能>/<页面>.spec.ts` | | API兼容性测试 | `e2e/` 根目录 | `<功能名>-api.spec.ts` | | Page Object | `pages/` 目录 | `<功能>/<页面>.page.ts` | | 测试fixtures | `fixtures/` 目录 | `<数据类型>.json` 或 `test-data.ts` | | 测试工具 | `utils/` 目录 | `<用途>.ts` | ## Playwright 配置规范 ### 基本配置 ```typescript import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ // 测试目录:当前目录,同时扫描specs/和根目录的.spec.ts文件 testDir: '.', // 并行执行 fullyParallel: true, // CI环境下禁止only测试 forbidOnly: !!process.env.CI, // 重试次数 retries: process.env.CI ? 2 : 0, // 并发worker数 workers: process.env.CI ? 1 : undefined, // 报告器 reporter: [ ['html'], // HTML报告 ['list'], // 控制台列表 ['junit', { outputFile: 'test-results/junit.xml' }] // JUnit XML报告 ], // 默认配置 use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:8080', trace: 'on-first-retry', // 第一次重试时记录trace screenshot: 'only-on-failure', // 失败时截图 video: 'retain-on-failure', // 失败时保留视频 }, // 测试项目配置 projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] }, }, ], // Web服务器配置 webServer: { command: 'npm run dev', url: 'http://localhost:8080', reuseExistingServer: !process.env.CI, timeout: 120000, }, }); ``` ## E2E测试类型 ### 1. 页面级E2E测试 **目的**: 验证用户在页面上的完整交互流程 **位置**: `specs/<功能>/<页面>.spec.ts` **示例**: 管理后台登录流程测试 ```typescript import { test, expect } from '../../utils/test-setup'; test.describe('登录页面 E2E 测试', () => { test.beforeEach(async ({ adminLoginPage }) => { await adminLoginPage.goto(); }); test('成功登录', async ({ adminLoginPage, dashboardPage }) => { await adminLoginPage.login('admin', 'admin123'); await dashboardPage.expectToBeVisible(); await expect(dashboardPage.pageTitle).toHaveText('仪表盘'); }); test('登录失败 - 错误密码', async ({ adminLoginPage }) => { await adminLoginPage.login('admin', 'wrongpassword'); await expect(adminLoginPage.errorToast).toBeVisible(); }); }); ``` ### 2. API兼容性测试 **目的**: 验证API端点路径和响应结构兼容性 **位置**: `e2e/<功能名>-api.spec.ts` **示例**: 统一广告API兼容性测试 ```typescript import { test, expect } from '@playwright/test'; test.describe('统一广告API兼容性测试', () => { const baseUrl = process.env.API_BASE_URL || 'http://localhost:8080'; let userToken: string; test.beforeAll(async ({ request }) => { // 登录获取token const loginResponse = await request.post(`${baseUrl}/api/v1/auth/login?tenantId=1`, { data: { username: 'admin', password: 'admin123' } }); const loginData = await loginResponse.json(); userToken = loginData.token; }); test('GET /api/v1/advertisements - 获取广告列表', async ({ request }) => { const response = await request.get(`${baseUrl}/api/v1/advertisements`, { headers: { 'Authorization': `Bearer ${userToken}` } }); expect(response.status()).toBe(200); const result = await response.json(); expect(result).toHaveProperty('code', 200); expect(result.data).toHaveProperty('list'); expect(Array.isArray(result.data.list)).toBeTruthy(); }); }); ``` ## Page Object Model ### 使用Page Object模式 封装页面交互逻辑,提高测试可维护性: #### 管理后台示例 ```typescript // pages/admin/login.page.ts import { Page, expect } from '@playwright/test'; export class AdminLoginPage { readonly page: Page; readonly usernameInput = this.page.locator('input[name="username"]'); readonly passwordInput = this.page.locator('input[name="password"]'); readonly submitButton = this.page.locator('button[type="submit"]'); readonly errorToast = this.page.locator('[role="alert"]'); readonly successToast = this.page.locator('.toast-success'); constructor(page: Page) { this.page = page; } async goto() { await this.page.goto('/admin/login'); await this.expectToBeVisible(); } async expectToBeVisible() { await expect(this.page).toHaveTitle(/管理后台登录/); await expect(this.usernameInput).toBeVisible(); } async login(username: string, password: string) { await this.usernameInput.fill(username); await this.passwordInput.fill(password); await this.submitButton.click(); } clone(page: Page) { return new AdminLoginPage(page); } } ``` #### 租户后台示例 ```typescript // pages/tenant/tenant-login.page.ts import { Page, Locator, expect } from '@playwright/test'; /** * 租户后台登录页面对象 * 路径: /tenant/login * 认证方式: 超级管理员登录(username=admin, password=admin123) */ export class TenantLoginPage { readonly page: Page; readonly usernameInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly pageTitle: Locator; readonly initializingText: Locator; constructor(page: Page) { this.page = page; this.usernameInput = page.getByPlaceholder('请输入用户名'); this.passwordInput = page.getByPlaceholder('请输入密码'); this.submitButton = page.getByRole('button', { name: '登录' }); this.pageTitle = page.getByRole('heading', { name: /租户.*登录|登录/i }); this.initializingText = page.getByText('应用初始化中'); } async goto() { await this.page.goto('/tenant/login'); // 等待应用初始化完成 try { await expect(this.initializingText).not.toBeVisible({ timeout: 30000 }); } catch { // 如果初始化文本没有出现,继续 } // 等待登录表单可见 await expect(this.pageTitle).toBeVisible({ timeout: 30000 }); } async login(username: string, password: string) { // 确保应用已初始化 try { await expect(this.initializingText).not.toBeVisible({ timeout: 10000 }); } catch { // 继续尝试 } // 等待输入框可见 await expect(this.usernameInput).toBeVisible({ timeout: 10000 }); await expect(this.passwordInput).toBeVisible({ timeout: 10000 }); await this.usernameInput.fill(username); await this.passwordInput.fill(password); await this.submitButton.click(); await this.page.waitForLoadState('networkidle'); } async expectLoginSuccess() { // 登录成功后应该重定向到租户控制台 await expect(this.page).toHaveURL(/\/tenant\/dashboard/); } async expectLoginError() { // 登录失败应该显示错误提示 const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]'); await expect(errorToast).toBeVisible(); } clone(newPage: Page): TenantLoginPage { return new TenantLoginPage(newPage); } } ``` #### 业务管理页面示例 ```typescript // pages/tenant/tenant-advertisement.page.ts import { Page, Locator, expect } from '@playwright/test'; /** * 租户后台广告管理页面对象 * 路径: /tenant/unified-advertisements */ export class TenantAdvertisementPage { readonly page: Page; readonly pageTitle: Locator; readonly createButton: Locator; readonly searchInput: Locator; readonly tableRows: Locator; readonly modalTitle: Locator; readonly titleInput: Locator; readonly submitButton: Locator; readonly deleteDialog: Locator; readonly deleteConfirmButton: Locator; constructor(page: Page) { this.page = page; // 列表页元素 this.pageTitle = this.page.getByRole('heading', { name: /广告管理/i }); this.createButton = this.page.getByTestId('create-unified-advertisement-button'); this.searchInput = this.page.getByTestId('search-input'); this.tableRows = this.page.locator('tbody tr'); // 表单元素 this.modalTitle = this.page.getByTestId('modal-title'); this.titleInput = this.page.getByTestId('title-input'); this.submitButton = this.page.locator('[data-testid="create-submit-button"], [data-testid="update-submit-button"]'); // 删除对话框元素 this.deleteDialog = this.page.getByTestId('delete-dialog'); this.deleteConfirmButton = this.page.getByTestId('confirm-delete-button'); } async goto() { await this.page.goto('/tenant/unified-advertisements'); await this.page.waitForLoadState('networkidle'); } async clickCreate() { await this.createButton.click(); await this.page.waitForTimeout(300); } async search(keyword: string) { await this.searchInput.fill(keyword); await this.page.waitForTimeout(500); // 等待搜索防抖 } async fillForm(data: { title: string; code?: string; url?: string }) { if (data.title) await this.titleInput.fill(data.title); // ... 其他字段 } async submitForm() { await this.submitButton.click(); await this.page.waitForTimeout(500); } async clickDelete(id: number) { const deleteButton = this.page.getByTestId(`delete-button-${id}`); await deleteButton.click(); await this.page.waitForTimeout(300); } async confirmDelete() { await this.deleteConfirmButton.click(); await this.page.waitForTimeout(500); } async expectModalVisible(visible: boolean = true) { if (visible) { await expect(this.modalTitle).toBeVisible(); } else { await expect(this.modalTitle).not.toBeVisible(); } } clone(newPage: Page): TenantAdvertisementPage { return new TenantAdvertisementPage(newPage); } } ``` ### 测试Setup工具 ```typescript // utils/test-setup.ts import { test as base } from '@playwright/test'; import { AdminLoginPage } from '../pages/admin/login.page'; import { DashboardPage } from '../pages/admin/dashboard.page'; type AdminFixtures = { adminLoginPage: AdminLoginPage; dashboardPage: DashboardPage; }; export const test = base.extend({ adminLoginPage: async ({ page }, use) => { const loginPage = new AdminLoginPage(page); await use(loginPage); }, dashboardPage: async ({ page }, use) => { const dashboardPage = new DashboardPage(page); await use(dashboardPage); }, }); ``` ## 认证测试规范 ### 认证要求 在多租户系统中,用户端API需要认证来确定租户上下文: ```typescript // 用户端API:使用 authMiddleware (多租户认证) const loginResponse = await request.post(`${baseUrl}/api/v1/auth/login?tenantId=1`, { data: { username: 'admin', password: 'admin123' } }); // 管理员API:使用 tenantAuthMiddleware (超级管理员专用) const adminResponse = await request.get(`${baseUrl}/api/v1/admin/unified-advertisements`, { headers: { 'Authorization': `Bearer ${authToken}` } }); ``` ### 测试前置条件 E2E测试需要数据库中有测试数据: ```sql -- 创建测试租户 INSERT INTO tenant_mt (id, name, code, status, created_at, updated_at) VALUES (1, '测试租户', 'test-tenant', 1, NOW(), NOW()); -- 创建测试用户 (密码: admin123,需bcrypt加密) INSERT INTO users_mt (id, tenant_id, username, password, registration_source, is_disabled, is_deleted, created_at, updated_at) VALUES (1, 1, 'admin', '$2b$10$x3t2kofPmACnk6y6lfL6ouU836LBEuZE9BinQ3ZzA4Xd04izyY42K', 'web', 0, 0, NOW(), NOW()); ``` ## 测试数据管理 ### 使用Fixtures ```typescript // fixtures/test-users.json { "admin": { "username": "admin", "password": "admin123", "email": "admin@example.com", "role": "admin" }, "regularUser": { "username": "testuser", "password": "test123", "email": "testuser@example.com", "role": "user" } } ``` ### 数据清理策略 - **事务回滚** (推荐) - 测试后自动回滚 - **数据库清理** (每个测试后) - 清理测试数据 - **测试数据隔离** (使用唯一标识符) - 避免数据冲突 ## 测试断言规范 ### 通用断言 ```typescript // 状态码断言 expect(response.status()).toBe(200); // 数组断言 expect(Array.isArray(data)).toBeTruthy(); // 属性断言 expect(ad).toHaveProperty('id'); expect(ad).toHaveProperty('title', '预期标题'); // 类型断言 expect(typeof ad.id).toBe('number'); // 可见性断言 await expect(element).toBeVisible(); await expect(text).toHaveText(/正则表达式/); ``` ### API响应格式断言 ```typescript // 包装响应格式 { "code": 200, "message": "success", "data": { "list": [], "total": 0, "page": 1, "pageSize": 10 } } // 断言 expect(result).toHaveProperty('code', 200); expect(result).toHaveProperty('data'); expect(result.data).toHaveProperty('list'); expect(result.data).toHaveProperty('total'); ``` ## 测试执行 ### 本地运行 ```bash # 运行所有E2E测试 cd web && pnpm test:e2e # 运行特定测试文件 pnpm test:e2e unified-ad # 运行Chromium测试 pnpm test:e2e:chromium # 使用UI模式运行 pnpm test:e2e:ui # 调试模式 pnpm test:e2e:debug # 列出所有测试 pnpm exec playwright test --config=tests/e2e/playwright.config.ts --list ``` ### 环境变量 ```bash # API基础URL export API_BASE_URL=http://localhost:8080 # 测试用户 export TEST_USERNAME=admin export TEST_PASSWORD=admin123 export TEST_TENANT_ID=1 ``` ## 最佳实践 ### 1. 使用test.describe分组 ```typescript test.describe('用户管理', () => { test.describe('创建用户', () => { test('应该成功创建用户', async ({ page }) => { // ... }); }); }); ``` ### 2. 使用beforeEach/beforeAll设置前置条件 ```typescript test.beforeEach(async ({ adminLoginPage }) => { await adminLoginPage.goto(); }); test.beforeAll(async ({ request }) => { // 执行一次,如登录获取token }); ``` ### 3. 使用test.skip合理跳过测试 ```typescript test('获取广告详情', async ({ request }) => { if (!userToken) { test.skip(true, '缺少认证token,请先创建测试用户'); } // ... }); ``` ### 4. 使用console.debug调试 ```typescript if (loginResponse.status() === 200) { console.log('✅ 登录成功,获取到token'); } else { const error = await loginResponse.json(); console.error('❌ 登录失败:', error); } ``` ### 5. 验证失败后查看页面结构 ```bash # E2E测试失败时先查看页面结构 cat test-results/**/error-context.md ``` ## 覆盖率要求 | 测试类型 | 最低要求 | 目标要求 | |----------|----------|----------| | 关键用户流程 | 100% | 100% | | 主要用户流程 | 80% | 90% | | 次要用户流程 | 60% | 80% | ## 常见问题 ### 1. 测试超时 **问题**: 测试超时失败 **解决**: ```typescript test.setTimeout(60000); // 增加超时时间 await page.waitForLoadState('networkidle'); // 等待网络空闲 ``` ### 2. 元素找不到 **问题**: 找不到页面元素 **解决**: ```typescript // 使用显式等待 await page.waitForSelector('.my-element', { timeout: 5000 }); // 使用data-testid await page.locator('[data-testid="submit-button"]').click(); ``` ### 3. 认证失败 **问题**: API返回401 **解决**: - 确保数据库中有测试用户 - 检查tenantId是否正确 - 验证token是否正确传递 ## 相关文档 - **[测试策略概述](./testing-strategy.md)** - 测试架构和原则 - **[Web UI包测试规范](./web-ui-testing-standards.md)** - Web UI组件测试 - **[编码标准](./coding-standards.md)** - 代码风格和最佳实践 ## 更新日志 | 日期 | 版本 | 描述 | |------|------|------| | 2026-01-03 | 1.0 | 初始版本,基于史诗010 E2E测试经验创建 | --- **文档状态**: 正式版 **下次评审**: 2026-02-03