feat(dev-test): 新增 API Mock E2E 冒烟测试模式,让 Gate 4 不再流于形式

解决 E2E 门禁依赖后端导致形同虚设的问题:
- 新增 page.route() API Mock 模式,零后端依赖
- 文档化 mock JWT 生成、addInitScript 注入、catch-all 兜底等关键模式
- 整理 6 个常见陷阱及解决方案
- 明确 Gate 4 判定标准和适用场景

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 11:06:17 +10:30
parent 58516a57a9
commit 63ab37c256
3 changed files with 273 additions and 37 deletions

View File

@@ -35,6 +35,18 @@
],
"strict": false
},
{
"name": "agent-browser-plugin",
"source": "./skills-dev/agent-browser-plugin",
"description": "浏览器自动化技能。用于网页交互、E2E冒烟测试、需求验收验证、前端开发验证、截图对比。基于 Vercel agent-browser CLI。",
"version": "1.0.0",
"category": "utility",
"keywords": [
"utility",
"tools"
],
"strict": false
},
{
"name": "dev-arch-plugin",
"source": "./skills-dev/dev-arch-plugin",

View File

@@ -13,7 +13,7 @@ description: 软件测试技能。用于单元测试、集成测试、E2E测试
| `frontend-testing.md` | Vue (Vitest) + React (Jest) 前端测试 |
| `ios-testing.md` | iOS 测试 (XCTest + Swift Concurrency) |
| `android-testing.md` | Android 测试 (JUnit + Espresso + Compose) |
| `e2e-testing.md` | E2E Playwright + Coolbuy PaaS 集成测试 |
| `e2e-testing.md` | E2E PlaywrightAPI Mock 冒烟测试(无后端)+ 全链路集成测试 |
---
@@ -46,8 +46,9 @@ description: 软件测试技能。用于单元测试、集成测试、E2E测试
| React | `npm test` | `frontend-testing.md` |
| iOS | `xcodebuild test` | `ios-testing.md` |
| Android | `./gradlew test` | `android-testing.md` |
| E2E (通用) | `npm run test:e2e` | `e2e-testing.md` |
| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` |
| E2E (Mock 冒烟) | `npm run test:e2e:smoke-mock` | `e2e-testing.md` §API Mock |
| E2E (全链路) | `npm run test:e2e` | `e2e-testing.md` §全链路 |
| E2E (Coolbuy PaaS) | `make e2e` | `e2e-testing.md` §Coolbuy |
---
@@ -138,4 +139,5 @@ ai-proj task append-doc --id <taskId> --content "# 测试报告
5. **持续集成** - 每次提交运行
6. **Biz 层禁止 Mock** - biz/service 层必须使用真实 PostgreSQL test DB + 真实 storemock 等于没测
7. **Mock 仅限 Handler 层** - handler 层可以 mock biz 接口 + httptest
7. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md`
8. **Gate 4 必须用 API Mock E2E** - E2E 门禁不能依赖后端,否则形同虚设。用 `page.route()` 拦截 API`e2e-testing.md`
9. **李宁测试用例** - Excel 导出见 `coolbuy-legacy` 技能的 `test-cases-excel.md`

View File

@@ -1,6 +1,259 @@
# E2E 测试 (Playwright)
## 通用 Playwright 配置
## 两种 E2E 测试模式
| 模式 | 后端依赖 | 速度 | 适用场景 | 门禁阶段 |
|------|---------|------|---------|---------|
| **API Mock 冒烟测试** | ❌ 无需后端 | 快(<30s | UI 布局、路由、菜单、权限隔离 | Gate 4 自动化门禁 |
| **全链路集成测试** | ✅ 需完整后端+DB | 慢(分钟级) | CRUD 业务流程、数据持久化 | 手动/CI |
**⚠️ 关键原则Gate 4 E2E 门禁必须使用 API Mock 模式,不依赖后端。** 依赖后端的 E2E 在开发机上经常跑不通后端没启动、DB 未初始化),导致门禁形同虚设。
---
## 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. Gate 4 集成dev-test 5-Gate 流程)
```bash
# package.json 新增命令
"test:e2e:smoke-mock": "playwright test --config=playwright.smoke.config.ts"
# Gate 4 执行流程:
# 1. 确认 chromium 已安装
npx playwright install chromium
# 2. 运行冒烟测试(自动启动 dev server
npm run test:e2e:smoke-mock
# 3. 解析 e2e-smoke-results.json 判定通过/失败
```
**Gate 4 判定标准**
- ✅ 全部通过 → Gate 4 Pass
- ⚠️ 部分失败但非核心路径 → 记录失败项,人工判定
- ❌ 核心路径失败(登录、主导航、角色隔离)→ Gate 4 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
@@ -20,49 +273,18 @@ export default defineConfig({
})
```
## 通用 E2E 测试示例
### 测试示例
```typescript
// login.spec.ts
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')
await expect(page.locator('.welcome')).toContainText('testuser')
})
test('invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="username"]', 'wrong')
await page.fill('[data-testid="password"]', 'wrong')
await page.click('[data-testid="submit"]')
await expect(page.locator('.error')).toBeVisible()
})
})
test.describe('Task Management', () => {
test.beforeEach(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"]')
})
test('create task', async ({ page }) => {
await page.click('[data-testid="new-task"]')
await page.fill('[data-testid="task-title"]', 'E2E Test Task')
await page.click('[data-testid="save"]')
await expect(page.locator('text=E2E Test Task')).toBeVisible()
})
})
```