# E2E 测试 (Playwright) ## 三种 E2E 测试模式 | 模式 | 后端依赖 | 速度 | 适用场景 | 门禁阶段 | |------|---------|------|---------|---------| | **API Mock 冒烟测试** | ❌ 无需后端 | 快(<30s) | UI 布局、路由、菜单、权限隔离 | TG4 E2E 冒烟门禁 | | **全链路集成测试** | ✅ 需完整后端+DB | 慢(分钟级) | CRUD 业务流程、数据持久化 | 手动/CI | | **部署后验收 (PDV)** | ✅ 真实部署环境 | 中(<2min) | 功能入口可达、菜单可见、API 连通 | `/req deploy` 步骤 6 | **⚠️ 关键原则:E2E 冒烟门禁必须使用 API Mock 模式,不依赖后端。** 依赖后端的 E2E 在开发机上经常跑不通(后端没启动、DB 未初始化),导致门禁形同虚设。 > **PDV 与 TG4 的区别**:TG4 在开发阶段用 API Mock 验证前端逻辑;PDV 在部署后用真实环境验证功能可达性。详见 `SKILL.md` §PDV 章节。 > **与 req-test-gate 的关系**:本文档定义 E2E 测试的**执行技术**(怎么写 mock、怎么跑)。质量门禁流程(Gates 0-5、scope 分级、文档持久化)定义在 `req-test-gate` 技能中。 --- ## API Mock 冒烟测试(无后端) > 使用 Playwright `page.route()` 拦截所有 API 请求,返回 fixture 数据。只启动前端 dev server,零后端依赖。 ### 核心思路 ``` 前端 dev server (localhost:3000) ↕ HTTP 请求 Playwright page.route() 拦截层 ← 替代真实后端 ↕ fixture 响应 浏览器渲染 ↕ Playwright 断言(UI 可见性、路由跳转、CSS 样式) ``` ### 1. Mock 工具函数模板(`e2e/utils/api-mock.ts`) ```typescript import { Page } from '@playwright/test'; export interface MockUserOptions { role: string; // 角色代码:tenant_admin, company_admin, admin 等 userType: string; // 用户类型:system, enterprise 等 username?: string; userId?: number; tenantId?: number; } // 构造 mock 用户对象(匹配前端 User interface) function buildMockUser(opts: MockUserOptions) { return { id: opts.userId ?? 1, username: opts.username ?? `test_${opts.role}`, email: `${opts.role}@test.com`, user_type: opts.userType, role: opts.role, tenant_id: opts.tenantId ?? 1, status: 'active', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }; } // ⚠️ 关键:生成能通过前端 TokenManager.isTokenValid() 校验的 mock JWT // TokenManager 检查:3 段 split、base64 解码 payload、exp > 当前时间 function buildMockJWT(user: ReturnType): string { const header = { alg: 'HS256', typ: 'JWT' }; const payload = { user_id: user.id, username: user.username, role: user.role, exp: Math.floor(Date.now() / 1000) + 3600, // 1 小时后过期 iat: Math.floor(Date.now() / 1000), }; const encode = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url'); return `${encode(header)}.${encode(payload)}.mock-signature`; } // 按角色构建菜单 fixture(根据项目实际菜单结构定制) function buildMockMenus(role: string) { const common = [ { key: '/', name: '首页', path: '/', children: [] }, ]; // 根据 role 添加不同的菜单项... return common; } /** * 设置完整 API Mock,调用后 page.goto('/') 即可渲染应用 */ export async function setupApiMocks(page: Page, opts: MockUserOptions) { const user = buildMockUser(opts); const menus = buildMockMenus(opts.role); const token = buildMockJWT(user); // ⚠️ 必须在导航前注入 localStorage(addInitScript 在每次导航前执行) await page.addInitScript(({ userData, authToken }) => { localStorage.setItem('token', authToken); localStorage.setItem('refreshToken', authToken); localStorage.setItem('currentUser', JSON.stringify(userData)); }, { userData: user, authToken: token }); // Mock 认证相关 await page.route('**/api/v1/auth/me', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: user }) }) ); await page.route('**/api/v1/auth/refresh', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ token, refresh_token: token }) }) ); // Mock 菜单 await page.route('**/api/v1/menus/user-menus', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: menus }) }) ); // Mock 权限(全部放行) await page.route('**/api/v1/users/*/permissions', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: { permissions: ['*'] } }) }) ); // Mock 列表接口(空数据) await page.route('**/api/v1/projects**', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: [], total: 0 }) }) ); // Catch-all:未覆盖的 API 返回空成功(并打日志) await page.route('**/api/**', route => { console.log(`[E2E Mock] Unhandled: ${route.request().method()} ${route.request().url()}`); route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: {} }) }); }); } ``` ### 2. 冒烟测试 Playwright Config ```typescript // playwright.smoke.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', testMatch: '**/*.spec.ts', fullyParallel: true, retries: process.env.CI ? 1 : 0, reporter: [['list'], ['json', { outputFile: 'e2e-smoke-results.json' }]], use: { baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', actionTimeout: 15000, navigationTimeout: 30000, }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, ], webServer: { command: 'npm run start', url: 'http://127.0.0.1:3000', reuseExistingServer: true, // ⚠️ 开发时复用已有 server timeout: 120 * 1000, }, // ⚠️ 不设置 globalSetup — 测试自己通过 setupApiMocks() 处理认证 }); ``` **与全链路 config 的关键差异**: - 无 `globalSetup` / `globalTeardown`(不需要真实登录流程) - 只用 chromium(速度优先) - `reuseExistingServer: true`(复用开发时已启动的 server) ### 3. 测试用例编写模式 ```typescript import { test, expect } from '@playwright/test'; import { setupApiMocks } from './utils/api-mock'; test.describe('角色菜单隔离', () => { test('tenant_admin 看到租户菜单', async ({ page }) => { await setupApiMocks(page, { role: 'tenant_admin', userType: 'enterprise' }); await page.goto('/'); await expect(page.getByText('租户用户')).toBeVisible({ timeout: 15000 }); await expect(page.getByText('组织管理')).not.toBeVisible(); }); test('company_admin 不看到租户菜单', async ({ page }) => { await setupApiMocks(page, { role: 'company_admin', userType: 'enterprise' }); await page.goto('/'); await expect(page.getByText('租户用户')).not.toBeVisible(); }); }); test.describe('路由重定向', () => { test('旧路径自动重定向', async ({ page }) => { await setupApiMocks(page, { role: 'tenant_admin', userType: 'enterprise' }); await page.goto('/old-path/users'); await expect(page).toHaveURL(/\/new-path\/users/, { timeout: 10000 }); }); }); ``` ### 4. 常见陷阱与解决方案 | 陷阱 | 现象 | 解决 | |------|------|------| | **token 格式不对** | 所有页面重定向到 `/login` | 必须生成 3 段式 JWT,payload 含 `exp > now`(见 `buildMockJWT`) | | **用 `page.evaluate` 设 localStorage** | 刷新后 token 丢失 | 改用 `page.addInitScript()`,每次导航前自动执行 | | **漏 mock 关键接口** | 页面卡在 loading 或白屏 | 跑一次看控制台 `[E2E Mock] Unhandled:` 日志,补上对应 route | | **route 注册顺序** | catch-all 吞掉了具体 route | Playwright route 按注册顺序匹配,**具体 route 先注册,catch-all 最后** | | **chromium 未安装** | `Executable doesn't exist` | `npx playwright install chromium` | | **前端 proxy** | API 请求未被 route 拦截 | `page.route()` 在浏览器层拦截,不受 webpack proxy 影响 | ### 5. 与 req-test-gate 门禁集成 本节的 API Mock 冒烟测试对应 `req-test-gate` 中以下阶段: - **Gate 2 → 2A (T4b)**: 前端构建验证(`vite build` / `craco build`) - **Gate 2 → 2C (IT6)**: 页面可访问性验证(API Mock 代替真实后端) - **Gate 5**: 回归贡献中的 E2E 场景描述 ```bash # package.json 新增命令 "test:e2e:smoke-mock": "playwright test --config=playwright.smoke.config.ts" # 执行流程: # 1. 确认 chromium 已安装 npx playwright install chromium # 2. 运行冒烟测试(自动启动 dev server) npm run test:e2e:smoke-mock # 3. 解析 e2e-smoke-results.json 判定通过/失败 ``` **判定标准**: - ✅ 全部通过 → Pass - ⚠️ 部分失败但非核心路径 → 记录失败项,人工判定 - ❌ 核心路径失败(登录、主导航、角色隔离)→ Fail,阻断发布 ### 6. 适合 Mock E2E 验证的场景 | 场景 | 验证点 | 示例断言 | |------|--------|---------| | **布局选择** | 不同角色渲染不同布局 | sidebar 颜色、logo、菜单结构 | | **菜单权限隔离** | A 角色看不到 B 的菜单 | `expect(text).not.toBeVisible()` | | **路由重定向** | 旧 URL 自动跳转 | `expect(page).toHaveURL(/new-path/)` | | **守卫跳转** | 未登录 → /login | 不设 token → 检查跳转 | | **条件渲染** | 根据 user_type 显示/隐藏组件 | 检查特定 `data-testid` | | **响应式布局** | 移动端 Drawer vs 桌面端 Sider | 设置 viewport 后检查 | **不适合**(应走全链路测试): - 表单提交 + 数据持久化 - 复杂的多步骤业务流程 - 数据一致性验证 --- ## 全链路集成测试(需后端) ### 通用 Playwright 配置 ```typescript // playwright.config.ts import { defineConfig } from '@playwright/test' export default defineConfig({ testDir: './tests/e2e', timeout: 30000, use: { baseURL: 'http://localhost:3000', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } }, ], }) ``` ### 测试示例 ```typescript import { test, expect } from '@playwright/test' test.describe('Login', () => { test('successful login', async ({ page }) => { await page.goto('/login') await page.fill('[data-testid="username"]', 'testuser') await page.fill('[data-testid="password"]', 'password') await page.click('[data-testid="submit"]') await expect(page).toHaveURL('/dashboard') }) }) ``` --- ## Coolbuy PaaS E2E 集成测试 > Playwright 全链路 E2E 测试,独立环境(DB + 端口),可与 dev 服务并行运行。 ### 环境架构 | 服务 | Dev 端口 | E2E 端口 | DB | |------|---------|---------|-----| | Auth Service | 7089 | 7189 | coolbuy_paas_e2e | | Foundation Service | 7090 | 7190 | coolbuy_paas_e2e | | ERP Service | 7091 | 7191 | coolbuy_paas_e2e | | Web Frontend | 4000 | 4010 | - | **E2E DB 初始化**(首次/重置): ```bash psql -U coolbuy-dev -d postgres -c "DROP DATABASE IF EXISTS coolbuy_paas_e2e;" psql -U coolbuy-dev -d postgres -c "CREATE DATABASE coolbuy_paas_e2e OWNER \"coolbuy-dev\";" pg_dump -U coolbuy-dev coolbuy_paas_local | psql -U coolbuy-dev coolbuy_paas_e2e ``` ### 启动 / 停止 E2E 服务 ```bash make e2e-start # 启动全部 E2E 服务(auth/foundation/erp/web) make e2e-stop # 停止全部 E2E 服务 make e2e-reset # 重置 DB 后启动 make e2e # 启动服务 + 运行全部测试 ``` 脚本位置:`scripts/start-e2e-services.sh` / `scripts/stop-e2e-services.sh` ### 运行测试 ```bash cd web # 全部测试(无头模式) npx playwright test # 带 UI 调试 npx playwright test --headed # 单个文件 npx playwright test tests/product-crud.spec.ts # 查看 HTML 报告(注意:会启动 HTTP server,需 Ctrl+C 退出) npx playwright show-report ``` ### Auth 自动登录 `tests/auth.setup.ts` 优先点击快速登录按钮(`VITE_ENABLE_QUICK_LOGIN=true`),降级为表单登录: ```typescript // 快速登录(E2E 环境默认开启) const quickLoginBtn = page.locator('button, a').filter({ hasText: /李宁|lining|ID:2/i }).first(); if (await quickLoginBtn.isVisible({ timeout: 3000 })) { await quickLoginBtn.click(); } else { // 降级:填写 lining_admin / admin123,验证码任意 4 位(SkipVerify=true) } await page.waitForURL(/\/tenant/, { timeout: 15000 }); await page.context().storageState({ path: authFile }); ``` Session 保存至 `.auth/user.json`,后续测试自动复用,无需重复登录。 ### 配置文件 | 文件 | 说明 | |------|------| | `web/.env.e2e` | E2E 环境变量(端口 / 快速登录开关) | | `web/playwright.config.ts` | baseURL=localhost:4010,reporter=[html, list] | | `auth-service/api/etc/auth-api-e2e.yaml` | E2E auth 配置(SkipVerify=true) | | `foundation-service/api/etc/foundation-api-e2e.yaml` | E2E foundation 配置 | | `erp-service/configs/config.e2e.yaml` | E2E ERP 配置 | ### 测试结果解读 当前 **113 tests — 103 ✅ / 10 ❌**,已知失败项: | 失败原因 | 涉及测试 | |---------|---------| | `/tenant/order/business` 路由 404(页面未实现) | 业务订单列表 × 3、订单模块导航 | | 预警管理无搜索表单组件 | 预警管理搜索/筛选 | | 库存管理页无 table/empty 状态 | 仓库管理-库存管理 | | 待审批订单 networkidle 超时(>60s) | 待审批订单列表 | | 数据权限/字段权限页渲染异常 | 系统管理 × 2 | | 业务 CRM 页渲染异常 | 业务 CRM | ### 常见问题 | 问题 | 解决 | |------|------| | `Executable doesn't exist` | `npx playwright install chromium` | | 端口 4010 被占用 | `make e2e-stop` 后重试 | | GORM migration 失败 | 检查 DB 是否有旧约束名,手动 DROP CONSTRAINT 后重启服务 | | HTML 报告进程不退出 | Playwright 在 report 模式会启动 HTTP server,用 `Ctrl+C` 停止 |