- dev-test: 移除 "Gate 4" 编号,改用 "E2E 冒烟门禁"(避免与 req-test-gate 的 Gate 编号冲突) - dev-test: 添加与 req-test-gate 的关系说明(本文档定义执行技术,门禁流程在 req-test-gate) - req-test-gate: 2C 联调节新增无后端替代方案提示,引用 dev-test 的 API Mock 模式 - req-test-gate: Gate 5 前端回归贡献引用 dev-test 的 e2e-testing.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
399 lines
14 KiB
Markdown
399 lines
14 KiB
Markdown
# E2E 测试 (Playwright)
|
||
|
||
## 两种 E2E 测试模式
|
||
|
||
| 模式 | 后端依赖 | 速度 | 适用场景 | 门禁阶段 |
|
||
|------|---------|------|---------|---------|
|
||
| **API Mock 冒烟测试** | ❌ 无需后端 | 快(<30s) | UI 布局、路由、菜单、权限隔离 | E2E 冒烟门禁 |
|
||
| **全链路集成测试** | ✅ 需完整后端+DB | 慢(分钟级) | CRUD 业务流程、数据持久化 | 手动/CI |
|
||
|
||
**⚠️ 关键原则:E2E 冒烟门禁必须使用 API Mock 模式,不依赖后端。** 依赖后端的 E2E 在开发机上经常跑不通(后端没启动、DB 未初始化),导致门禁形同虚设。
|
||
|
||
> **与 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<typeof buildMockUser>): 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` 停止 |
|