在 /req deploy 流程中增加部署后 E2E 验收(Post-Deploy Verification)门禁: - 新增 verification linkRole 和【验收】任务命名规范 - Deploy Gate 1 健康检查 / Gate 2 PDV 任务完成 / Gate 3 证据完整 - PDV Playwright spec 模板(页面可达、菜单可见、API 连通) - 同步更新 req-workflow、dev-test、e2e-testing 相关文档 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
14 KiB
14 KiB
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)
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
// 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. 测试用例编写模式
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 场景描述
# 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 配置
// 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' } },
],
})
测试示例
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 初始化(首次/重置):
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 服务
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
运行测试
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),降级为表单登录:
// 快速登录(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 停止 |