ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AxonHub E2E 测试规范:data-testid 与账户池实践

AxonHub E2E 测试规范:data-testid 与账户池实践

E2E 测试这件事,在我们团队前后折腾了三轮,前两轮都死在了维护成本上。第一轮用例写得挺欢,UI 一改版全线飘红;第二轮把选择器换成 CSS 类名加文本匹配,看着稳了一点,结果设计稿调整一次文案,二十多条用例集体罢工。到第三轮我们才想明白,问题不在工具,在于没有规范——>// components/Button.tsx type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { testId: string; // 必填,不允许省略 variant?: 'primary' | 'secondary' | 'ghost'; }; export function Button({ testId, variant = 'primary', children, ...rest }: ButtonProps) { return ( <button type="button" >// 推荐:先锁定容器,再用业务语义过滤 const row = page.getByTestId('order-list-row').filter({ hasText: 'ORD-20240513-001' }); await row.getByTestId('order-list-row-action-view').click(); // 不推荐:下标定位,排序一变就错 await page.getByTestId('order-list-row').nth(2).click();

如果每一行都有自己的 testid 后缀(比如带业务 ID),那更简单,直接精确定位。但要注意,getByTestId在 Playwright 里是支持精确匹配的,不要写成模糊匹配,否则row-1会匹配到row-10。

弹窗场景有个坑:弹窗可能同时存在多个(比如一个叠加在另一个上面),或者关闭后 DOM 还没销毁。我们的做法是给弹窗容器固定一个{模块}-{名称}-dialog的 testid,用例里先断言弹窗可见,再在弹窗容器内部找元素,避免全局搜索误伤:

const dialog = page.getByTestId('user-detail-edit-dialog'); await expect(dialog).toBeVisible(); await dialog.getByTestId('user-detail-edit-dialog-name-input').fill('张三'); await dialog.getByTestId('user-detail-edit-dialog-confirm').click();

表格里的排序、分页控件同理,用{模块}-table-sort-{字段名}这种命名,字段名跟后端返回的字段保持一致,这样别人一眼就知道点的是哪一列。

2.5 让规范长出牙齿:lint 和 CI 双保险

光靠约定必有遗漏,我们加了两道自动检查。

第一道是 ESLint 自定义规则,检查 JSX 里使用我们封装组件时是否传了testId。这条其实 TypeScript 已经能管住大部分,但对div、span这些原生元素接管不了,所以我们额外加了一条正则检查:components/目录下出现>#!/usr/bin/env bash # scripts/check-selectors.sh set -euo pipefail SPEC_DIR="e2e/specs" FAIL=0 # 禁止出现裸 CSS 层级选择器 if grep -rnE "locator\(['\"](div|span|ul|li)[ >]" "$SPEC_DIR"; then echo "发现裸标签层级选择器,请改用># e2e/fixtures/accounts.yaml version: 3 tenants: - key: main name: 主租户 accounts: - key: admin email: e2e+admin@example.test role: tenant_admin perms: [user.read, user.write, order.read, order.refund, settings.write] tags: [smoke, regression] - key: operator email: e2e+operator@example.test role: operator perms: [order.read, order.refund] tags: [regression] - key: viewer email: e2e+viewer@example.test role: viewer perms: [order.read] tags: [regression] - key: isolated name: 隔离租户 accounts: - key: admin email: e2e+iso-admin@example.test role: tenant_admin perms: [user.read, user.write] tags: [isolation]

几个设计点值得解释。

version字段是幂等初始化的依据。种子脚本会读这个版本号,跟数据库里记录的版本比对,不一致才重新初始化,一致就直接跳过。这样本地反复跑测试不会每次都被重置,节省大量时间。

tags用来标记账号的适用范围。冒烟用例只跑smoke标签的账号,回归全量跑。这样可以做分层,日常提交只跑冒烟,发版前跑全量。

第二个租户isolated是专门为租户隔离测试准备的。SaaS 产品里"跨租户越权"是最危险的问题之一,必须有用例覆盖:用 A 租户的账号去访问 B 租户的资源,必须被拒。这需要至少两个独立的租户,且它们的数据不能互相可见。

密码不写在文件里,全部走环境变量:

E2E_ACCOUNT_PASSWORD_ADMIN=... E2E_ACCOUNT_PASSWORD_OPERATOR=...

本地开发用.env.local,这个文件在.gitignore里;CI 里用仓库的 secret 注入。这样做的好处是即便仓库权限管理出了疏漏,也不会泄露测试环境的凭据。有人会问测试环境密码泄露有什么大不了的——如果测试环境里有脱敏的真实数据,那就不是小事了。

3.3 权限矩阵:让每个角色都有明确的预期

账户体系的核心不是"有几个号",而是"每个号能干什么、不能干什么"。我们把权限矩阵写成了表格,这张表同时是用例设计的输入和种子脚本的执行依据。

角色用户管理订单查看订单退款系统设置数据导出
tenant_admin读写只读允许读写允许
operator无权限只读允许无权限无权限
viewer无权限只读禁止无权限无权限
auditor只读只读禁止只读允许

有了这张表,正向用例和反向用例都能写得很顺:正向是"operator 能点退款按钮",反向是"viewer 看不到退款按钮"。而且反向用例特别有价值——按钮不该出现的时候出现了,这类 bug 在代码 review 里极难发现,因为开发往往只测了有权限的情况。

心得:权限类用例一定要断言"元素不存在",而不是断言"点击后报错"。前者能在渲染层就拦住越权,后者说明前端已经把入口暴露给用户了,性质完全不同。

3.4 幂等初始化:重复执行不能产生副作用

种子脚本是整个账户体系里最容易写砸的部分。我见过太多脚本是"先删再建",本地跑没问题,一上并行就互相删对方的数据。正确的做法是幂等 upsert,按唯一键判断存在与否,存在则更新到期望状态,不存在则创建。

// e2e/fixtures/seed.ts import { readFileSync } from 'node:fs'; import { parse } from 'yaml'; type Account = { key: string; email: string; role: string; perms: string[]; tags: string[]; }; export async function seedAccounts(api: TestApiClient) { const config = parse(readFileSync('e2e/fixtures/accounts.yaml', 'utf8')); const remoteVersion = await api.getSeedVersion(); if (remoteVersion === config.version) { console.log(`种子数据版本一致 (v${config.version}),跳过初始化`); return; } console.log(`检测到版本变更:${remoteVersion} -> ${config.version},开始初始化`); for (const tenant of config.tenants) { const tenantId = await api.upsertTenant({ key: tenant.key, name: tenant.name }); for (const acc of tenant.accounts as Account[]) { const password = process.env[`E2E_ACCOUNT_PASSWORD_${acc.key.toUpperCase()}`]; if (!password) throw new Error(`缺少环境变量:E2E_ACCOUNT_PASSWORD_${acc.key.toUpperCase()}`); // upsert 语义:以 email 为唯一键 await api.upsertUser({ tenantId, email: acc.email, password, role: acc.role, permissions: acc.perms, // 标记为测试数据,方便后续按前缀清理 meta: { origin: 'e2e-seed', seedVersion: config.version }, }); } } await api.setSeedVersion(config.version); console.log('种子初始化完成'); }

这里有几个细节。upsertUser必须由后端提供幂等语义的接口,如果没有,前端测试团队要推动后端加一个仅供测试环境使用的内部接口,或者用测试环境的管理 API 实现。别想着在前端用 UI 操作去注册账号,那既慢又不稳。

meta.origin这个标记很关键。它让我们可以在需要时精确清理所有测试数据,而不会误伤手工造的数据。清理逻辑就是一句查询加批量删除,按origin: 'e2e-seed'过滤。

版本比对的逻辑避免了每次跑测试都执行一遍全量初始化。本地开发时这条路径能省下十几秒,日积月累很可观。当账户配置变化时,手动改一下 YAML 里的version就能触发重新初始化。

3.5 并行执行下的账户分配策略

Playwright 的并行模型是多个 worker 进程同时跑不同的 spec 文件,每个 worker 有独立的 browser context。这意味着同一个账号可能被两个 worker 同时使用,如果这个账号关联的数据会被修改,冲突就来了。

我们的解决思路分两层。

第一层是按 spec 文件划分账号。在test.describe.configure里声明这个文件使用哪个账号,尽量让不同文件用不同账号。这需要账户池里有足够的号,我们的实践是每个高频模块配两到三个同角色账号,用后缀-1、-2区分。

第二层是数据隔离用时间戳和随机后缀。账号可以复用,但每条用例创建的业务数据必须带唯一标识:

// e2e/fixtures/data.ts import { randomUUID } from 'node:crypto'; export function uniqueName(prefix: string) { const stamp = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); return `${prefix}_${stamp}_${randomUUID().slice(0, 6)}`; }

生成了类似订单_20240513103012_a3f9c1的名字。这样即便两个 worker 同时创建订单,也不会互相覆盖。用例内部的所有断言都基于这个唯一名字,不会误判。

具体到 fixture 的写法:

// e2e/fixtures/auth.ts import { test as base, expect } from '@playwright/test'; type AccountKey = 'admin' | 'operator' | 'viewer'; export const test = base.extend<{ loginAs: (key: AccountKey) => Promise<void> }>({ loginAs: async ({ page }, use) => { await use(async (key: AccountKey) => { const email = `e2e+${key}@example.test`; const password = process.env[`E2E_ACCOUNT_PASSWORD_${key.toUpperCase()}`]; if (!password) throw new Error(`缺少密码环境变量:${key}`); await page.goto('/login'); await page.getByTestId('auth-login-email-input').fill(email); await page.getByTestId('auth-login-password-input').fill(password); await page.getByTestId('auth-login-submit').click(); await expect(page.getByTestId('app-shell-user-menu')).toBeVisible(); }); }, }); export { expect };

这个 fixture 的好处是登录逻辑只写一次,所有用例复用。更进一步,可以用storageState缓存登录态,避免每条用例都走一遍登录流程:

// e2e/auth.setup.ts import { test as setup, expect } from '@playwright/test'; const accounts = ['admin', 'operator', 'viewer'] as const; for (const key of accounts) { setup(`准备 ${key} 登录态`, async ({ page }) => { // 登录逻辑同上 await page.context().storageState({ path: `.auth/${key}.json` }); }); }

然后在playwright.config.ts里把.auth/{key}.json挂到对应 project 的storageState上。实测下来,一百条用例的登录开销从三分钟压到接近零,收益非常直观。

3.6 敏感信息与配置管理

最后说说配置管理,这块做不好前面全白搭。

所有密码、token 一律走环境变量,代码里出现硬编码就视为缺陷。本地用.env.local,CI 用 secret 注入,两条路径分开。日志输出要做脱敏,console.log(page)这种随手打印在调试时很常见,但如果把整页 HTML 打出来,里面可能含有用户的真实邮箱或者接口返回的敏感字段。我们的做法是在 fixture 里包一层日志函数,只打印 testid 和操作动作,不打印页面内容。

另外,测试账户的邮箱统一用e2e+xxx@example.test这种格式。+后缀在多数邮件系统里是别名,方便归集;.test是保留域名,永远不会误发给真实用户。这种小细节看着琐碎,但确实能避免一些不该发生的事故。

4. 从零搭一条能跑起来的流水线

4.1 目录结构和配置骨架

规范讲完,落到工程上。我们的目录结构是这样的:

e2e/ ├── fixtures/ # 账户配置、数据工厂、自定义 fixture │ ├── accounts.yaml │ ├── auth.ts │ ├── data.ts │ └── seed.ts ├── specs/ # 用例,按模块分目录 │ ├── auth/ │ │ ├── login.spec.ts │ │ └── permission.spec.ts │ ├── order/ │ │ └── refund.spec.ts │ └── user/ │ └── profile.spec.ts ├── pages/ # Page Object,封装常用操作 │ ├── login.page.ts │ └── order-list.page.ts ├── auth.setup.ts # 登录态预热 └── global-setup.ts # 全局种子初始化 playwright.config.ts

pages/这层要不要,团队里有过争论。我的观点是要,但别做厚。Page Object 只封装"页面上的稳定动作",比如登录、导航、填表,不要去封装具体的断言。一旦 Page Object 里塞满断言,改一个业务规则要动好几处,反而更难维护。我们的原则是:一个 Page Object 最多十几个方法,超过就说明它在承担不该承担的职责。

配置文件的关键项:

// playwright.config.ts import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e/specs', globalSetup: './e2e/global-setup.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: process.env.CI ? 4 : undefined, reporter: [ ['html', { outputFolder: 'e2e-report', open: 'never' }], ['json', { outputFile: 'e2e-report/result.json' }], ['list'], ], use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', trace: 'retain-on-failure', video: 'retain-on-failure', screenshot: 'only-on-failure', actionTimeout: 10_000, navigationTimeout: 20_000, }, projects: [ { name: 'setup', testMatch: /auth\.setup\.ts/ }, { name: 'chromium-admin', use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' }, dependencies: ['setup'], testMatch: /.*\/admin\/.*\.spec\.ts/, }, { name: 'chromium-operator', use: { ...devices['Desktop Chrome'], storageState: '.auth/operator.json' }, dependencies: ['setup'], testMatch: /.*\/operator\/.*\.spec\.ts/, }, ], });

retries在 CI 上给了 1,本地是 0。这个取舍要说清楚:本地重试会掩盖问题,你永远不知道自己的用例是不是 flaky;CI 上给一次重试是为了防止偶发的网络抖动导致整条流水线失败。但重试不是万能药,如果一个用例连续两周都需要重试才能过,那它就是个定时炸弹,必须查。

trace: 'retain-on-failure'只保留失败用例的 trace,这个配置在磁盘占用和排查能力之间平衡得最好。全量保留 trace 会让产物体积暴涨,CI 存储很快就不够用。

4.2 全局初始化的实现要点

globalSetup负责在跑用例前把账户和数据准备好。它的执行时机在所有 worker 之前,只跑一次。

// e2e/global-setup.ts import { request } from '@playwright/test'; import { seedAccounts } from './fixtures/seed'; async function globalSetup() { const apiBase = process.env.E2E_API_BASE ?? 'http://localhost:8080'; const ctx = await request.newContext({ baseURL: apiBase }); // 健康检查,环境没起来就早点失败,别让用例跑一半全红 const health = await ctx.get('/health'); if (!health.ok()) { throw new Error(`测试环境不可用:${health.status()},请先启动服务`); } await seedAccounts(ctx); await ctx.dispose(); } export default globalSetup;

健康检查这一步很多人会省掉,但它能省下大量排查时间。如果后端没起来,你不加检查,结果就是五十条用例全部超时失败,日志里全是 timeout,你得挨个看才知道根因。加了检查,第一条错误信息直接告诉你"服务没启动",一目了然。

4.3 一条完整的退款用例长什么样

把上面所有东西串起来,看一条真实场景的用例:

// e2e/specs/order/operator/refund.spec.ts import { test, expect } from '../../../fixtures/auth'; import { uniqueName } from '../../../fixtures/data'; test.describe('订单退款', () => { test('operator 能对已支付订单发起退款', async ({ page, loginAs }) => { await loginAs('operator'); const orderName = uniqueName('退款订单'); // 前置数据通过 API 准备,不走 UI,快且稳 const orderId = await test.step('准备一笔已支付订单', async () => { const res = await page.request.post('/api/orders', { data: { name: orderName, amount: 19900, status: 'paid' }, }); expect(res.ok()).toBeTruthy(); const body = await res.json(); return body.id; }); await page.goto(`/orders/${orderId}`); await expect(page.getByTestId('order-detail-status')).toHaveText('已支付'); await page.getByTestId('order-detail-refund-button').click(); const dialog = page.getByTestId('order-detail-refund-dialog'); await expect(dialog).toBeVisible(); await dialog.getByTestId('order-detail-refund-dialog-amount-input').fill('199'); await dialog.getByTestId('order-detail-refund-dialog-reason-select').click(); await page.getByRole('option', { name: '用户申请' }).click(); await dialog.getByTestId('order-detail-refund-dialog-confirm').click(); await expect(page.getByTestId('order-detail-status')).toHaveText('退款中'); await expect(page.getByTestId('app-shell-toast-success')).toBeVisible(); }); test('viewer 看不到退款入口', async ({ page, loginAs }) => { await loginAs('viewer'); await page.goto('/orders'); await expect(page.getByTestId('order-list-table')).toBeVisible(); await expect(page.getByTestId('order-list-refund-button')).toHaveCount(0); }); });

几个细节值得说。前置数据走 API 不走 UI,这是效率的关键,UI 准备数据要多点五次,每次都可能因为动画或加载而失败。测试数据带唯一名,避免和别的 worker 冲突。状态断言用 toHaveText 这种自动重试的断言,不要用expect(await el.textContent()),后者没有重试,一旦渲染有延迟就假失败。反向用例断言 toHaveCount(0),直接验证入口不存在。

4.4 CI 上怎么分片跑

用例数上百之后,串行跑要十几分钟,必须分片。Playwright 内置的--shard参数配 CI 矩阵最省事:

# .github/workflows/e2e.yml 之类的 CI 配置示意 name: e2e on: pull_request: branches: [main] schedule: - cron: '0 18 * * *' jobs: e2e: timeout-minutes: 30 strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright install --with-deps chromium - run: npx playwright test --shard=${{ matrix.shard }}/4 env: E2E_BASE_URL: ${{ secrets.E2E_BASE_URL }} E2E_API_BASE: ${{ secrets.E2E_API_BASE }} E2E_ACCOUNT_PASSWORD_ADMIN: ${{ secrets.E2E_ACCOUNT_PASSWORD_ADMIN }} E2E_ACCOUNT_PASSWORD_OPERATOR: ${{ secrets.E2E_ACCOUNT_PASSWORD_OPERATOR }} E2E_ACCOUNT_PASSWORD_VIEWER: ${{ secrets.E2E_ACCOUNT_PASSWORD_VIEWER }} - uses: actions/upload-artifact@v4 if: always() with: name: e2e-report-shard-${{ matrix.shard }} path: e2e-report/ retention-days: 7

分片数怎么定?我们一开始拍脑袋定 4,实测发现总时长没有明显下降,因为环境是瓶颈——四个分片同时打同一个测试环境,数据库连接池被打满,反而更慢。后来改成 3,并且给每个分片配了独立的租户,才有明显收益。

这里的经验是:分片数要和环境承载能力匹配,不是越多越好。先串行跑一遍看看单条用例耗时,再估算分片后的总时长,如果分片后总时长反而上升,说明环境扛不住,该加资源或者减分片。

4.5 失败产物怎么用

产物留存的意义在于让失败可复现。trace打开后,在本地解压可以一步步回看当时的 DOM、网络请求、控制台输出,比看一行报错有用得多。

我们的习惯是:CI 失败后第一件事不是重跑,是下载 trace 看一眼。如果 trace 里能看到明确的原因(比如某个接口返回 500),那就是真 bug,报给后端;如果 trace 里一切正常但断言就是失败了,那多半是时序问题,去检查用例里有没有缺少等待。

为了让 trace 更容易读,我们要求用例里用test.step包住关键阶段。这样 trace 里的时间轴是有语义的,你看到的是"准备订单 → 打开详情 → 发起退款 → 断言状态",而不是一堆散乱的点击。

5. 常见问题与排查技巧实录

5.1 flaky 用例的定位方法论

flaky 是 E2E 最大的敌人。我的排查路径固定四步,基本能覆盖九成情况。

第一步,复现。用--repeat-each=10把可疑用例连跑十次,看失败率。如果十次全过,说明它依赖了执行顺序或者环境状态,问题不在用例本身,而在数据。如果十次里挂两三次,那就是典型的时序或者竞态问题。

第二步,看 trace。重点看失败那一刻的 DOM 快照,确认元素在不在、可见不可见、是不是被别的元素遮挡。有一类问题特别隐蔽:元素其实存在,但被一个透明浮层盖住了,Playwright 的 actionability 检查会一直等到超时,报错是 "element is not visible",实际原因是z-index或者pointer-events。

第三步,查数据。把失败用例用到的测试数据单独拿出来看,是不是被别的用例改过。我们之前有个用例总是偶发失败,查了两天,最后发现是另一个文件里的清理逻辑把它的数据提前删了。

第四步,加日志。在前面都排除之后,在失败点附近加page.on('console')和page.on('response')的监听,把控制台错误和 4xx/5xx 响应打出来。多数时候能直接看到根因。

5.2 问题速查表

把这两年踩过的坑整理成表,遇到问题先对号入座:

现象大概率原因处理方式
元素找不到,但页面上明明有testid 拼写不一致 / 元素在 iframe 内检查组件里实际写的 testid;用 frameLocator 切进去
一直等到超时,报 element not visible被浮层遮挡 / 动画未结束查 z-index 和 pointer-events;等动画结束或关掉动画
本地全绿,CI 全红环境配置差异 / 时区 / 字体渲染对齐环境变量;统一时区为 UTC;避免依赖像素位置
单跑能过,一起跑就挂数据污染 / 账号冲突检查数据唯一性;给不同 spec 分配不同账号
偶发的 strict mode violation同一个 testid 挂了多个元素收紧命名,给列表项加业务 ID 后缀
登录后立刻跳回登录页storageState 过期 / cookie 域不匹配缩短缓存有效期;检查 baseURL 与 cookie domain
分片后总时长不降反升环境承载不足减少分片数或增加测试环境资源
断言文本失败但内容看着一样首尾空格 / 全角半角 / 换行用 toHaveText 配合正则,或先 trim
上传文件的用例在 CI 挂文件路径使用了相对路径用path.join(__dirname, ...)拼绝对路径
倒计时类断言永远不稳定依赖真实时间用接口或注入的方式固定时间,别等真实倒计时

5.3 几条只能靠时间换来的经验

第一条,别把等待写成 sleep。page.waitForTimeout(2000)是我们 code review 里的红线。它要么太短导致假失败,要么太长拖慢整体时长,而且随着环境变慢会逐渐失效。正确做法是等状态,expect(locator).toBeVisible()自带轮询,waitForResponse等接口,都能精确等到该等的东西。

第二条,选择器要跟着业务走,不要跟着实现走。这一点前面讲过,但值得再强调。我们有个页面做过一次大重构,组件从三个拆成七个,因为 testid 是业务语义的,用例一行没改就全过了。这就是规范带来的复利。

第三条,测试数据必须可识别、可清理。所有 E2E 造的数据都带统一前缀或者 meta 标记,清理脚本按标记批量删。否则跑上几个月,测试环境里堆满垃圾数据,列表页翻十页都看不到自己造的那条。

第四条,回归用例要克制。不是所有流程都值得写 E2E。我们现在的标准是:核心链路(登录、下单、结算、权限)必须覆盖;边缘功能优先用集成测试;纯展示逻辑交给单元测试。E2E 用例数控制在六十到八十条,维护成本还能接受,超过一百条就得有专人负责,否则很快就没人管了。

第五条,让失败信息自己说话。断言的时候尽量带上上下文,expect(status, '退款后订单状态应为「退款中」').toHaveText('退款中'),这样失败报告里直接能看到期望是什么。这一个小习惯,能省下大量"这条用例在测什么"的沟通成本。

这套东西跑了一年多,我们从"每周都在修用例"变成了"每周看一次报告",最大的变化不是工具变强了,而是约定变成了代码。data-testid 是必填属性,账户是配置驱动的,检查是 CI 强制的,人只需要在规则不明的地方做判断。如果你们团队也在被 E2E 的维护成本拖累,建议先从选择器契约开始,改动最小、见效最快,等选择器稳住了再去啃账户体系,一步一步来,别想着一次性全推。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进