
前端工程做到一定规模E2E 测试就会从锦上添花变成卡脖子的那一环。AxonHub 这个项目在去年下半年踩过一次非常典型的坑一次跟业务毫无关系的样式重构把侧边栏容器的 class 从sidebar换成了side-nav第二天早上回归流水线红了十七条用例三个人排查了一上午最后发现全部是选择器失效一行业务逻辑都没坏。那次之后我们定了一条硬规矩——所有 E2E 用例只允许通过>#!/usr/bin/env bash set -euo pipefail echo 扫描 E2E 目录中的非规范选择器... BAD$(grep -rnE (locator\([\]\.|locator\([\]#|locator\([\][a-z]\[|text) e2e/ || true) if [ -n $BAD ]; then echo 检测到以下不规范选择器请改用>type BaseButtonProps { testId: string; variant?: primary | ghost; disabled?: boolean; onClick?: () void; children: React.ReactNode; }; export function BaseButton({ testId, variant primary, disabled, onClick, children, }: BaseButtonProps) { return ( button typebutton >export function AppModal({ testId, open, onClose, children }: AppModalProps) { return ( Modal open{open} onCancel{onClose} footer{null} div>{orders.map((o, i) ( li>ul>// e2e/fixtures/accountPool.ts type Tier read | write | admin | frozen; export type Account { username: string; password: string; leaseId: string; tier: Tier; }; const POOL_API process.env.POOL_API ?? http://test-pool.internal/api; export async function acquireAccount(tier: Tier): PromiseAccount { const res await fetch(${POOL_API}/lease, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ tier, ttl: 600 }), }); if (res.status 409) { throw new Error([${tier}] 账户池已耗尽请检查并发配置或扩容); } if (!res.ok) { throw new Error(账户申请失败状态码 ${res.status}); } return (await res.json()) as Account; } export async function releaseAccount(leaseId: string): Promisevoid { const res await fetch(${POOL_API}/return, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ leaseId }), }); if (!res.ok) { // 归还不阻塞用例结果但必须记录否则池子会慢慢漏水 console.warn(账户归还失败: ${leaseId}, 状态码 ${res.status}); } }接入测试框架用 fixture 最自然use前后自动包裹用例即使抛异常也能归还// e2e/fixtures/base.ts import { test as base } from playwright/test; import { acquireAccount, releaseAccount, type Account } from ./accountPool; export const test base.extend{ account: Account }({ account: async ({}, use) { const acc await acquireAccount(write); await use(acc); await releaseAccount(acc.leaseId); }, });这里有个关键细节归还必须在finally语义下执行。Playwright 的 fixture 在use之后的代码即使测试失败也会执行这是它比手写beforeEach/afterEach更可靠的地方。我见过不少团队用afterEach归还结果用例超时被强杀的时候afterEach根本没跑池子一天漏掉几十个账号。租约 TTL 设 10 分钟比单条用例的超时上限长一点就行。服务端要有兜底的过期回收任务扫到过期未归还的租约直接收回并标记为脏交给夜间重置任务处理。这层兜底非常必要因为进程被 kill、机器掉线这些情况一定会发生你不能指望客户端永远优雅退出。3.4 并发数和账户池大小的换算这是最容易被拍脑袋决定的地方。我们的换算过程是这样的。先定单机的 worker 数。E2E 是 I/O 密集型的理论上可以开很大但实际上受限于被测环境能扛住的请求量。我们实测下来单机 4 个 worker 时单条用例平均耗时 12 秒开到 8 个反而涨到 15 秒因为后端开始排队了。所以定 4。再看 CI 同时跑几个 job。我们的回归分三个分片按业务模块切也就是 3 个 job 并行每个 job 4 个 worker峰值并发就是 12 个。那么 L1 账户池至少要 12 个这是硬下限。但实际配了 16 个留了 33% 的余量。原因有两个一是 worker 释放账户有延迟归还请求本身要走网络二是某些用例内部会串行申请第二个账号比如验证邀请好友流程需要两个账号这类用例会临时多占一个。公式可以简化成池大小 峰值并发 worker 数 × 1.3向上取整12 × 1.3 15.6取 16。这个系数在你们的归还逻辑足够可靠的前提下可以降到 1.2但别再低了容易在高峰期出现409 账户池已耗尽。另外强烈建议加一条监控账户池使用率超过 80% 持续五分钟就告警。这条告警救过我们好几次都是因为某批用例异常没有归还池子在慢慢漏水等涨到 100% 才开始查就晚了。4. 从零跑通一条完整用例4.1 工程初始化与关键配置E2E 单独一个目录独立package.json不要跟前端主工程混在一起。混在一起的问题是依赖版本互相牵制前端升级个构建工具 E2E 可能就跑不起来了。mkdir e2e cd e2e npm init -y npm i -D playwright/test typescript npx playwright install --with-deps配置文件里几个参数值得单独说// e2e/playwright.config.ts import { defineConfig } from playwright/test; const isCI !!process.env.CI; export default defineConfig({ testDir: ./specs, timeout: 45_000, expect: { timeout: 8_000 }, retries: isCI ? 2 : 0, workers: isCI ? 4 : 2, fullyParallel: true, reporter: [ [list], [html, { open: never, outputFolder: report }], [json, { outputFile: report/result.json }], ], use: { baseURL: process.env.BASE_URL ?? http://uat.internal, trace: retain-on-failure, screenshot: only-on-failure, video: retain-on-failure, }, });timeout给 45 秒是因为我们最慢的一条用例走完整下单流程实测 28 秒留了一半余量。如果给 30 秒会时不时因为环境抖动超时白白消耗重试次数。expect.timeout给 8 秒大于大部分接口的 P99 响应时间我们统计是 2.3 秒又不至于让失败用例卡太久。trace用retain-on-failure而不是on是因为全量开 trace 会让整个跑批时间翻倍磁盘也扛不住。只在失败时保留正好覆盖排障需求。4.2 登录态复用别每条用例都去登录这是新手最容易忽略的优化点。如果每条用例都走一遍登录 UI20 条用例就是 20 次登录每次 3 秒光登录就浪费一分钟。而且登录本身也是被测功能一旦登录页出问题全部用例一起红你根本分不清是登录坏了还是业务坏了。做法是在globalSetup里统一登录一次把存储状态存成文件其余用例直接复用// e2e/global-setup.ts import { chromium, type FullConfig } from playwright/test; import { acquireAccount, releaseAccount } from ./fixtures/accountPool; import fs from node:fs; async function globalSetup(config: FullConfig) { const acc await acquireAccount(read); const browser await chromium.launch(); const context await browser.newContext({ baseURL: config.projects[0].use.baseURL }); const page await context.newPage(); await page.goto(/login); await page.getByTestId(login-username-input).fill(acc.username); await page.getByTestId(login-password-input).fill(acc.password); await page.getByTestId(login-submit-btn).click(); await page.getByTestId(home-header-avatar).waitFor(); await context.storageState({ path: auth/read-user.json }); await browser.close(); await releaseAccount(acc.leaseId); fs.writeFileSync(auth/meta.json, JSON.stringify({ createdAt: Date.now() })); } export default globalSetup;然后在配置里声明projects: [ { name: read-only, use: { storageState: auth/read-user.json }, }, ],这里有个坑必须提登录态是有有效期的。我们的 token TTL 是 2 小时全量回归跑 25 分钟看起来够用。但如果这个storageState文件被缓存了比如 CI 上一次跑完的产物被复用就可能拿到一个已经过期的状态导致所有用例在第一步就 401。所以我在meta.json里记了生成时间用例启动前校验一下超过 30 分钟就重新生成。这个 30 分钟是拍出来的经验值比 token TTL 的一半略小既能复用又不容易过期。4.3 用例本体与断言写法一条规范的用例应该长这样我拿创建订单并校验列表举例import { test, expect } from ../fixtures/base; test.describe(订单模块, () { test(创建订单后列表应出现新记录, async ({ page, account }) { // 前置造一条独立数据不依赖环境中已有数据 const orderNo E2E-${Date.now()}-${Math.floor(Math.random() * 1000)}; await page.goto(/order/create); await page.getByTestId(order-create-no-input).fill(orderNo); await page.getByTestId(order-create-amount-input).fill(199.00); await page.getByTestId(order-create-submit-btn).click(); // 断言创建成功提示 await expect(page.getByTestId(order-create-success-toast)).toBeVisible(); // 跳转到列表并校验 await page.getByTestId(order-create-goto-list-btn).click(); await expect(page.getByTestId(order-list-loading)).toBeHidden(); const row page.getByTestId(new RegExp(^order-list-item-no-\\d$)) .filter({ hasText: orderNo }); await expect(row).toHaveCount(1); }); });几个点值得解释。数据自造自清。订单号里带时间戳和随机数保证每次执行都不同避免和历史数据冲突。这比跑之前先清库要优雅得多因为清库会干扰别的并行用例。断言加载态而不是等元素。expect(loading).toBeHidden()是幂等的元素立刻消失也能过如果是expect(row).toBeVisible()遇到列表加载慢的时候会有一段无意义的等待。用 hasText 过滤而不是拼接精准 testid。因为 testid 里带的是业务 ID测试侧不一定知道后端生成的。用正则匹配前缀 hasText 过滤两头兼顾。断言要少而准。很多人喜欢在一条用例里断言七八个东西结果一出错不知道是哪一步挂的。我倾向于一条用例只验证一个业务行为多个观察点可以拆成多条。代价是执行时间变长但排障效率提升明显。4.4 失败重试、产物与报告retries: 2在 CI 上是标准操作但它是一把双刃剑。好处是能过滤掉真正的环境抖动坏处是会掩盖 flaky 用例。我们的做法是单独统计重试后通过的比例flaky 率 重试后通过的用例数 / 总用例数这个指标超过 3% 就要拉出来专项治理。治理方法也简单把 flaky 用例单独标记跑 20 次看失败分布如果失败是集中在某一步那就针对那一步加一个显式等待或者换一个断言方式。产物方面trace 是最值钱的。失败用例下载trace.zip用npx playwright show-trace trace.zip打开能看到每一步的 DOM 快照、网络请求、控制台输出。这个体验比看截图强太多尤其是定位元素存在但点不到这类问题。所以我们的报告页面上直接把 trace 链接暴露出来省得每次手动翻 CI 产物目录。报告用 HTML 给团队看JSON 给自动化系统消费。JSON 那份会被一个脚本解析把失败用例的title、error.message、trace 路径汇总成一条消息推送到项目群这样值班的同学不用点进 CI 就能判断要不要立刻处理。5. 常见问题速查与踩坑记录5.1 排查速查表跑了大半年问题基本集中在下面这几类整理成表方便对照。现象高频原因排查动作解决方式整批用例全红登录态过期或账户池耗尽看第一条失败用例的报错检查auth/文件时间戳重新生成 storageState扩容账户池单条用例随机红数据污染或共享账号冲突连续跑 10 次看失败率改用 L1 独立账户数据加随机后缀元素存在但点击超时被遮罩层覆盖或动画未结束看 trace 里的截图等待遮罩toBeHidden后再点strict mode violation同名 testid 出现多份查 DOM 里是否有隐藏副本关闭即卸载或用>