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

14 KiB
Raw Blame History

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

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

// 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 段式 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 场景描述
# 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: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 serverCtrl+C 停止