
最近在给团队搭 Playwright E2E 框架,从项目结构、标签策略到 CI/CD 分层跑法,折腾了小两个月。这篇把踩过的坑和最终的方案捋清楚,供有类似需求的同学参考。
不做多余铺垫,直接进正题。假设你的产品够大:多个独立功能域(登录注册、下单支付、搜索、消息、计费、第三方集成等),测试要跑在从本地开发环境到阿里云多地域生产环境的全链路。
E2E 测试写到一定规模,核心矛盾就三个:跑得太慢(PR 合并等半天)、失败信号不靠谱(假阳性太多)、失败后定位不到人。下面的所有设计决策都是围绕这三个点来的。
所有项目共享一份 playwright.base.config.ts,定义通用的报告器、失败时截图/录屏、默认超时等横切配置。每个子项目再按需覆盖。这样改一处全局配置,所有域都能生效,不会出现改了这忘了那的情况。
不要搞一个大测试池子按文件名分。按功能域拆成独立的 Playwright 项目——checkout、search、onboarding、messaging、billing 等,每个项目指向自己的 testDir。这样做有两个好处:
总有一个域会变成拖后腿的那个——通常是带媒体上传、图像处理、AI 生成等耗时操作的业务。当单个域开始主导整体耗时,就把它拆成多个项目,共享同一套代码目录但按 spec 文件分组:
<domain>-core —— 快速 UI 逻辑的用例<domain>-heavy —— 耗时的媒体/处理/生成用例<domain>-endurance —— 隔离出来的超长耗时用例(后面单独说)关键经验:按实际耗时均衡拆分,不要按功能名称想当然。 目标是让各个 Job 的实际耗时接近。定期看 HTML 报告中每个 spec 的耗时,有必要就重新分配——凭感觉分的组迟早有一个闲着、另一个卡脖子。
涉及设备采集的用例需要 Chromium 能接 mock 媒体流,不然本地跑会报硬件错误:
launchOptions: { args: [ '--use-fake-device-for-media-stream', '--auto-accept-camera-and-microphone-capture', '--autoplay-policy=no-user-gesture-required', ],
}
这些参数只加在需要的项目上,全局乱加只会让不需要的域也背着无用的启动开销。
.env 覆盖命令行参数加载 .env 时用 override: false:
dotenv.config({ path: '../.env', override: false });
原因很重要——这是个静默 Bug:加了 override: true 的话,你命令行传的 APP_ENVIRONMENT=production pnpm exec playwright test … 会在 setup 运行前被 .env 里的默认值覆盖,结果就是"以为在跑生产环境"实际上跑的是预发。override: false 让命令行参数的优先级最高,.env 只填命令行没设置的坑。
这是大多数团队投入最少的部分,但恰恰是让整套测试可维护、可追溯的关键。用 Playwright 的运行时 tag 属性维护两个正交的标签维度。
维度 1 — 可追溯性 维度 2 — 运行层级 (对标哪个测试用例?) (什么时候跑?) ┌──────────────────┐ ┌────────────────────────┐ 一个测试 ──────►│ @TC042 │ ──加───►│ @smoke │ │ (对接测试管理库 │ │ @production-validation │ │ 的稳定关联键) │ │ @endurance / (无标签) │ └──────────────────┘ └────────────────────────┘ 文件重命名不破坏关联 决定走哪条流水线
每个 test/describe 带上 @TCxxx 标签,对应测试管理系统里的某行数据。这个标签是对 spec 和测试用例记录的稳定关联键——文件改名、重构都不会打断这个链路。
test.describe('Complete checkout with saved card', { tag: ['@TC042'] }, () => { ... });
为什么用运行时的 tag 而不是标题里的字符串或 JSDoc 注释?
tags: [...],自动化对账任务可以直接同步结果到测试管理系统。JSDoc 根本到不了报告器;标题里的前缀还要额外解析。--grep @TC042 跑单个用例;--grep @smoke 跑整个层级。tag,生态一致。多用例标签——仅限顺序依赖的场景。 当几个测试用例其实是同一条用户旅程的连续步骤(比如第三方集成:连接 → 拉数据 → 执行操作 → 推送结果),共用认证/状态,只在文件里打多个标签,然后用 test.step('TCxxx: …') 给每步命名,这样报告里失败仍能定位到具体步骤:
test('connect, fetch, and push to the external system', async ({ page }) => { await test.step('TC101: connect the integration', async () => { ... }); await test.step('TC102: view connected details', async () => { ... }); await test.step('TC103: push a record', async () => { ... });
});
判断标准:这些场景能不能在任何顺序、独立用全新状态跑? 能 → 拆成独立测试。不能,后一步依赖前一步 → 合并成多用例标签的单个测试。 把有依赖的旅程拆开跑,意味着每个步骤都要单独承担认证、远程连接和 fixture 初始化的成本。
跟用例 ID 无关,每个测试自己决定属于哪个运行层级:
| 标签 | 触发时机 |
|---|---|
@smoke |
每个 PR(精心挑选的子集) |
@production-validation |
每次生产发版后,fan-out 到各地域 |
| (无层级标签) | 完整回归,夜间跑 |
@endurance |
独立低频定时任务专用 |
烟雾测试是常驻的 PR 卡点,设计上要有约束:
超长耗时用例(比如一个耗时十几分钟、跑实时音视频的用例)就是反例:它不能进 smoke,更不能进夜间回归,要单独开一个 Playwright 项目,独立 Job,独立定时任务。
经验:把异常耗时的测试单独开辟隔离车道,永不让它的慢阻塞合并队列。
基础配置参考:
// CI = 3 workers,本地 = 4(顺序 IdP 登录的安全上限)
// 可通过 PLAYWRIGHT_WORKERS 环境变量覆盖
export const NUM_WORKERS = process.env.PLAYWRIGHT_WORKERS ? parseInt(process.env.PLAYWRIGHT_WORKERS, 10) : process.env.CI ? 3 : 4;
非直觉的经验:worker 数量上限取决于最弱的共享依赖,不是 CI 机器的 CPU 核数。
常见的瓶颈有两类:一是身份提供商(IdP)对短时间内并发登录的容忍度有限,二是被测环境的共享资源承载能力。worker 数调太高,不仅不会变快,反而会产生更多"失败"——这些失败本质是后端或 IdP 被打满了,但看起来像测试抖动。把 worker 数做成环境变量驱动的旋钮(PLAYWRIGHT_WORKERS),就能针对不同环境调参,不用改代码。
每次生产发版后——发版节奏快的团队甚至会更频繁地跑——执行一套小而稳定的核心流程用例,在所有地理区域 fan-out 跑,通过手动触发的 workflow 调度。设计原则:
playwright test --grep @production-validation --list 查看当前清单,作为唯一事实来源。PR 打开 ──────────────► @smoke (按域矩阵并行,1 worker,<5 分钟预算) │
夜间 ────────────────► 完整回归 (无层级标签的全部用例) │
每次发版 ────────────► @production-validation (多地域 fan-out,静态账号) (独立车道) ──────────► @endurance (独立定时任务,隔离项目)
每个层级在覆盖范围和速度之间做有意识的取舍。PR 获得快速、窄范围的反馈;夜间回归获得广度;生产获得小而高置信度的核心路径检查,覆盖多地域。
dotenv override: false)。"跑错了环境"的静默 Bug 极难排查。