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:
@@ -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",
|
||||
|
||||
@@ -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 Playwright:API 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 + 真实 store,mock 等于没测
|
||||
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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ⚠️ 必须在导航前注入 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. 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()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user