Files
ai-proj-helper/skills-dev/dev-test-plugin/skills/dev-test/e2e-testing.md
John Qiu 2309e31e74 refactor(skills): 澄清 dev-test 与 req-test-gate 的职责边界
- 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>
2026-03-16 11:40:47 +10:30

399 lines
14 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 测试 (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);
// ⚠️ 必须在导航前注入 localStorageaddInitScript 在每次导航前执行)
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 段式 JWTpayload 含 `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:4010reporter=[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` 停止 |