Playwright 工程化验证:从页面断言到 CI 证据链
CI 红了三次,第四次为什么又绿了
合并请求里的结算流程在开发机上连续通过,进入 CI 后却出现三种结果:第一次报“保存按钮不可点击”,第二次找到了两个同名按钮,第三次超时,手工重跑又全绿。团队于是把等待时间从 5 秒加到 30 秒,再把重试从 0 加到 3。流水线暂时安静了,但没人能回答究竟是页面慢、定位器歧义、账号数据冲突,还是浏览器版本已经漂移。
这类现场最危险的不是红灯,而是没有解释力的绿灯。固定睡眠只会推迟检查时刻;重试会改变 worker、浏览器进程和服务端数据状态;共享账号让并发用例互相覆盖;只保留最后一次截图,则会丢掉首轮失败时的 DOM、网络和控制台证据。一个可靠的 Playwright 工程必须同时固定运行版本、隔离状态、等待用户可感知的条件,并在失败时保存足以反推原因的证据。
Playwright Test 不只是控制浏览器的 API。官方安装说明将 runner、断言、隔离、并行和调试工具放在同一个测试框架里;测试进程再驱动与该 Playwright 版本配套的 Chromium、Firefox 或 WebKit。于是一次用例至少经过这条链路:test runner 分配 worker,worker 启动 browser,fixture 为用例创建 browser context 与 page,locator 在动作发生时重新解析页面,web-first assertion 重复取值,reporter 最后收集步骤和附件。
先按证据分型,而不是先改 timeout:
| 第一证据 | 更可能的故障 | 下一步 |
|---|---|---|
locator resolved to 2 elements | 语义定位不唯一或页面重复渲染 | 检查可访问名称、DOM 快照和产品语义 |
element is not enabled / intercepts pointer events | actionability 未满足 | 检查遮罩、动画、disabled 状态和业务请求 |
net::ERR_*、响应码或请求缺失 | 服务、代理、证书、Service Worker 或 mock 规则 | 查 trace 的 Network 与服务日志 |
| 本地过、CI 浏览器无法启动 | 二进制、系统库、沙箱、共享内存或镜像版本 | 查安装日志和容器参数 |
首轮失败、重试通过并被标记 flaky | 状态、时序或外部依赖不稳定 | 保留首轮与重试证据,建立 owner 和修复期限 |
安装的是测试框架,下载的是另一组运行时
Playwright 的 Node、操作系统和浏览器支持线会随发行版变化,长期正文不能把某次查询到的版本表当成永久承诺。建立项目基线时先读官方安装要求与发布说明,确认目标 Node 和 runner 系统仍受支持,再让 lockfile 固定实际解析到的 @playwright/test 版本。包元数据中的 engines.node 只是 npm 安装约束,也不能替代官方支持矩阵。升级评审应同时记录 Node、Playwright 包版本、浏览器 revision 和 CI 镜像,不能只改其中一个数字。
已有 Node 项目适合显式安装依赖,新项目也可以用 npm init playwright@latest 生成配置、示例和 CI 工作流。团队仓库应使用项目本地 CLI 和 lockfile,不要依赖全局 playwright:
node --version
npm --version
npm install --save-dev @playwright/test
npm pkg get devDependencies.@playwright/test
npx playwright --version
# 先为主门禁安装 Chromium,矩阵扩展时再增加 firefox/webkit
npx playwright install chromium
npx playwright test --project=chromiumnpm install 安装 runner、客户端和协议实现;playwright install chromium 下载当前包所要求的浏览器 revision。官方浏览器管理说明明确指出,每个 Playwright 版本依赖特定浏览器二进制,升级包后可能需要重新运行安装命令。若日志出现 Executable doesn't exist 或提示执行 playwright install,先比较 npx playwright --version、lockfile 与浏览器缓存,而不是把任意 Chrome 路径塞进 executablePath。
Linux 还需要字体、图形、音视频与系统动态库。CI 镜像可运行:
# Debian/Ubuntu runner,安装浏览器和它的系统依赖
npx playwright install --with-deps chromium
# 已有浏览器缓存,只补操作系统依赖
npx playwright install-deps chromium
# 只运行默认 Chromium headless shell 时可减少下载
npx playwright install --with-deps --only-shell chromiuminstall-deps 会调用系统包管理器,因此需要相应提权;它不应成为普通测试步骤里每次都以 root 运行的模糊脚本。更稳妥的做法是在受审查的 CI 基础镜像阶段安装系统依赖,测试作业使用非特权账号运行。受代理限制时,浏览器默认从 Microsoft CDN 下载,可按官方代理与内部制品库配置设置 HTTPS_PROXY、企业根证书或 PLAYWRIGHT_DOWNLOAD_HOST。不要为了绕过证书错误在测试配置中全局开启 ignoreHTTPSErrors,下载信任链和被测站点 TLS 是两个不同问题。
浏览器默认进入操作系统缓存,例如 Windows 的 %USERPROFILE%\AppData\Local\ms-playwright 和 Linux 的 ~/.cache/ms-playwright。缓存键至少包含操作系统、CPU 架构、Playwright 锁定版本与目标浏览器;只按 package-lock.json 缓存一个跨平台目录,会让 Windows 路径、Linux 可执行位和不同 revision 混在一起。安装成功的判断也不能止于目录存在,至少要实际启动一次浏览器并完成一个断言。
一份配置怎样改变整个执行拓扑
playwright.config.ts 顶层字段控制 runner,use 控制每个 browser context 的默认选项。把 workers、retries 放进 use 不会得到想要的调度行为;官方配置参考也明确区分了这两层。下面是一份可直接落入项目的基线:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
outputDir: './test-results',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['blob'], ['line']]
: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'off',
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
viewport: { width: 1440, height: 900 },
},
webServer: {
command: 'npm run preview -- --host 127.0.0.1',
url: 'http://127.0.0.1:4173/health',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stderr: 'pipe',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});timeout 覆盖测试函数、test-scoped fixture setup 与 beforeEach;测试函数结束后,fixture teardown 与 afterEach 共享另一份同值预算。慢 worker fixture 还可以声明独立 fixture timeout。expect.timeout 则是每个异步断言自己的重试预算,和测试总预算不是同一个计时器。官方超时说明给出了这几层关系;把它们一起拉大只会延迟失败。官方 CI 指南建议先用单 worker 保证资源独占和可复现,再用 shard 分散到多个作业;资源充足且数据、账号与端口已经隔离时,才逐步提高单机 workers。fullyParallel 即使在单 worker 作业中也会把分片粒度细化到 test,帮助 shard 均衡;提高 workers 后,它还会启用 test 级并行,因此测试必须真正独立。forbidOnly 防止开发者遗留 test.only 后让 CI 只跑一个用例。baseURL 让 page.goto('/orders') 与环境地址解耦。locale、timezoneId 和 viewport 固定容易漂移的浏览器输入,不过涉及国际化、夏令时或响应式布局时,仍要增加独立 project,而不是假设一个环境代表全部用户。
webServer.url 应指向能表示“应用已就绪”的地址,而不是仅仅端口已监听。数据库迁移没完成、前端代理未连接时,TCP 端口可能已开放,页面仍会失败。官方 webServer 说明还指出:reuseExistingServer: !process.env.CI 适合本地复用开发服务,CI 则应拒绝占用端口的未知进程;停止时 runner 会按平台和配置终止进程组,容器应用若需要优雅退出,应把 gracefulShutdown 与应用信号处理一起验证。
project 不是“浏览器别名”,而是一组完整配置。官方项目文档允许用它表达浏览器、设备、登录与未登录状态、环境、重试和超时差异。不要把生产、预发和浏览器做成笛卡尔积后无条件全跑:主门禁可选一套 Chromium 快速矩阵,定时或发布门禁再运行 Firefox/WebKit 与关键设备;媒体编解码、企业浏览器策略或真实 Safari 接近度要求高时,还要根据浏览器差异说明选择 branded channel 或 macOS 上的 WebKit。
Locator 等到的是可操作状态,不是一段固定时间
page.locator() 和 getByRole() 返回的是定位逻辑,不是已经冻结的 DOM 节点。每次动作或断言发生时,locator 会重新解析当前页面。优先级应从用户语义出发:角色与可访问名称、label、文本,再到稳定的 test id;易变 CSS 层级、自动生成 class 和 nth() 应是最后手段。官方定位器指南把 locator 定义为自动等待与可重试能力的核心。
一次 locator.click() 在真正派发事件前会检查:定位结果恰好一个、元素可见、稳定、能接收事件并且已启用;未在 timeout 内满足就抛出 TimeoutError。完整检查矩阵见官方actionability 说明。严格性针对要求单一目标的动作,不等于所有 locator 都只能匹配一个元素:toHaveCount()、allTextContents() 这类列表操作就是有意处理多个结果。因此“元素在 DOM 里”“locator 匹配了一组元素”和“用户能点击唯一目标”是三种状态。force: true 只关闭该动作的部分非必要检查,例如 click 不再验证是否能接收事件;它不会把歧义 locator 变成可靠目标,也不能作为遮罩或动画问题的常规修复。
下面的正向实验让按钮延迟启用,再由点击把状态从 Draft 改成 Saved。它没有 waitForTimeout:
import { expect, test } from '@playwright/test';
test('等待按钮可操作并验证用户结果', async ({ page }) => {
await page.setContent(`
<button aria-label="Save order" disabled>Save</button>
<p role="status">Draft</p>
<script>
setTimeout(() => {
const button = document.querySelector('button');
button.disabled = false;
button.addEventListener('click', () => {
document.querySelector('[role=status]').textContent = 'Saved';
});
}, 250);
</script>
`);
await page.getByRole('button', { name: 'Save order' }).click();
await expect(page.getByRole('status')).toHaveText('Saved');
});实际执行时,动作等待约 250ms 后点击成功,断言读取到 Saved。这里证明的不是“Playwright 会等”,而是两段不同机制:click 的 actionability 等到按钮 enabled;toHaveText 又独立重复获取 locator,直到业务结果出现。官方断言说明称这类异步 matcher 为 web-first assertion。相反,expect(await locator.textContent()).toBe('Saved') 只在某一瞬间取一次字符串,普通 expect 不会替它重试。
不要用 networkidle、全局 loading 消失或任意 sleep 代替业务完成条件。单页应用可能持续有遥测、轮询和 WebSocket,网络永远不“空闲”;loading 消失也可能只是请求失败。更可靠的断言是用户结果与关键协议结果共同成立,例如状态提示为“保存成功”,并且 waitForResponse 捕获的订单请求返回预期状态和业务字段。
反向实验要稳定暴露歧义,而不是偶尔撞上错误
模糊定位器最常见的坏味道是 .first():页面原本只有一个“提交”,后来弹窗也出现同名按钮,脚本继续点击 DOM 中排在前面的那个。测试仍绿,用户路径却已经走偏。反向实验应让错误稳定发生:
import { expect, test } from '@playwright/test';
test('同名按钮必须由语义容器消除歧义', async ({ page }) => {
await page.setContent(`
<main><button>Submit</button></main>
<dialog open><button>Submit</button></dialog>
`);
await expect(page.getByRole('button', { name: 'Submit' })).toHaveCount(1);
});在 expect.timeout = 3000 下,实际失败信息是:
Error: expect(locator).toHaveCount(expected) failed
Locator: getByRole('button', { name: 'Submit' })
Expected: 1
Received: 2
Timeout: 3000ms
Call log:
- waiting for getByRole('button', { name: 'Submit' })
10 x locator resolved to 2 elements这份输出提供了定位表达式、期望值、实际数量和重复解析次数。正确修复取决于产品意图:如果要操作弹窗,使用 page.getByRole('dialog').getByRole('button', { name: 'Submit' });如果两个按钮对用户真的无法区分,应先修正可访问名称。把断言改成 toHaveCount(2) 只能证明重复存在,把 locator 改成 .first() 则会掩盖交互歧义。
排查超时时可临时执行 npx playwright test --debug 或 PWDEBUG=1,但最终证据要回到 locator、action log、DOM snapshot、网络与控制台。DEBUG=pw:api 能输出 API 调用日志;日志中若一直显示 element is not stable,查动画和布局抖动;若显示 intercepts pointer events,查覆盖层;若 locator 从 0 变 2,查重复请求、hydration 或弹窗。每一种现象都对应不同修复,统一延长 timeout 只会让它们更晚暴露。
Browser、Context、Page 和 Fixture 的生命周期
Playwright Test 的内置 fixture 把资源所有权分得很清楚:browser 为 worker 共享以降低启动成本,context 对每个测试隔离 cookie、localStorage、权限和页面,page 属于该 context。官方fixture 文档也以“每个测试只得到所需资源”解释这种模型。BrowserContext 类似轻量无痕会话,但不是虚拟机;它隔离浏览器状态,不会自动隔离服务端数据库、消息队列和第三方账号。
自定义 fixture 应围绕“创建、交给测试、无论成败都清理”的 use() 边界设计:
import { test as base, expect } from '@playwright/test';
type Fixtures = {
order: { id: string; owner: string };
};
export const test = base.extend<Fixtures>({
order: async ({ request }, use, testInfo) => {
const runId = (process.env.E2E_RUN_ID ?? 'local').replace(/[^a-zA-Z0-9_-]/g, '-');
const shardId = (process.env.E2E_SHARD_ID ?? 'single').replace(/[^a-zA-Z0-9_-]/g, '-');
const owner = `e2e-${runId}-${shardId}-${testInfo.workerIndex}-${testInfo.retry}`;
const created = await request.post('/api/test-support/orders', {
data: { owner, status: 'DRAFT' },
});
expect(created.ok()).toBeTruthy();
const order = await created.json();
try {
await use({ id: order.id, owner });
} finally {
const removed = await request.delete(`/api/test-support/orders/${order.id}`);
expect.soft(removed.ok(), '测试订单清理失败').toBeTruthy();
}
},
});
export { expect };fixture 的 finally 比 afterEach 更接近资源所有者,也能在断言失败时执行。清理接口需要测试环境专用身份、资源前缀和最小权限:只能删除本作业创建的 e2e-* 数据,不能接受任意生产 ID。若 cleanup 失败,应作为附件或软断言进入报告,并由周期任务扫描残留;静默吞掉会让下一次并发测试读取脏数据。
test-scoped fixture 适合页面、订单、临时文件;worker-scoped fixture 适合昂贵且能在同一 worker 安全共享的资源。worker 之间是独立 OS 进程,不能靠进程内全局变量协调。官方并行模型说明,每个 worker 启动自己的浏览器,失败后 worker 会被关闭;因此 beforeAll、worker fixture 和登录动作可能在新 worker 中再次执行。服务端数据若按“只会创建一次”设计,重试时就会冲突。
登录态不是测试夹具,而是一份高价值凭证
storage state 默认保存 cookie 与 localStorage;认证信息位于 IndexedDB 时,要显式调用 storageState({ indexedDB: true }) 才会把它纳入快照。sessionStorage 不属于 storage state,依赖它的应用需要在创建 context 时通过受控初始化脚本恢复必要值,并把这段额外状态按凭证保护。它让每个新 context 从已登录状态开始,却也可能包含能直接冒充测试账号的 session。官方认证指南要求把状态放到 playwright/.auth 并加入 .gitignore,明确警告不要提交到公开或私有仓库;具体字段行为可从 browserContext.storageState()核对。
没有服务端状态冲突的只读用例,可以由 setup project 登录一次,其余 project 依赖它:
import { expect, test as setup } from '@playwright/test';
import path from 'node:path';
const authFile = path.join(process.cwd(), 'playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('用户名').fill(process.env.E2E_USER ?? '');
await page.getByLabel('密码').fill(process.env.E2E_PASSWORD ?? '');
await page.getByRole('button', { name: '登录' }).click();
await expect(page.getByRole('banner')).toContainText('测试环境');
await page.context().storageState({ path: authFile });
});projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium-authenticated',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
]共享登录只在所有并行测试不会修改同一账号服务端状态时成立。订单、收藏、权限、租户设置会被修改时,应为每个并发槽位分配独立账号或租户。这里必须区分两个索引:workerIndex 标识一次具体 worker 进程,进程因失败重启后会变化;parallelIndex 位于 0..workers-1,重启后保持同一槽位。官方认证指南因此使用 parallelIndex 选择账号池,而临时数据名可以再加入 run id、shard id、workerIndex 和 retry,防止不同进程或机器碰撞。账号池要有租约:借出时记录 run id、shard、并发槽位和过期时间,归还时清理数据和撤销临时授权;作业取消后由回收任务兜底。不能让并发槽位数大于可用账号数后随机等待,否则吞吐抖动会被误诊为页面 flaky。
CI secret 只在登录步骤的进程环境中注入,日志禁显,权限只指向隔离测试环境。一次性验证码优先由测试身份提供者、受控 API 或短期 token 生成,不要关闭真实账号的 MFA,也不要把 TOTP seed 写进代码。storage state 在每次作业内生成并短期使用,结束后删除;状态过期应重新登录,而不是把长寿命 cookie 固化成仓库资产。
还要注意“登录成功”的断言不能只看 URL。错误的环境、降级页或缓存页都可能落到相同路径。应同时验证环境标识、当前角色和一个最小授权行为;管理员 state 不应被普通用户用例复用,否则越权缺陷会天然看不见。
网络控制既能隔离依赖,也能制造假绿
page.route 只影响一个 page,context.route 影响该 context 内的页面与弹窗;两者都能监听、修改、放行或 fulfill HTTP(S) 请求。官方网络指南说明 XHR 和 fetch 都可处理。对于“前端如何渲染已知响应”的测试,route 能减少外部服务噪声;对于“前后端契约是否真的兼容”的测试,则必须让请求穿过真实测试服务。
下面的用例把 profile 请求固定为可审查响应,并验证页面结果:
import { expect, test } from '@playwright/test';
test('渲染受控 profile 响应', async ({ context, page }) => {
await context.route('**/api/profile', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'CI user', role: 'viewer' }),
});
});
await page.goto('/profile');
await expect(page.getByRole('heading', { name: 'CI user' })).toBeVisible();
await expect(page.getByText('viewer')).toBeVisible();
});mock 必须先注册再导航,否则首个请求可能已经发出。glob 要足够精确,并对请求方法、关键 body 和命中次数做断言;**/api/** 一把接管所有接口,会让未建模的新请求静默通过假数据。HAR 回放适合固定大型响应,但 HAR 可能含 Authorization、Cookie、查询参数、客户数据和内部域名。官方HAR mock 指南说明匹配会考虑 URL、方法、POST payload 和 headers;更新 HAR 后要像代码一样审查差异,并在提交前脱敏。
Service Worker 会截走请求,使 browserContext.route() 看不到预期网络事件。出现“浏览器明明请求了,route 却没命中”时,根据官方网络排障提示临时设置 serviceWorkers: 'block' 验证分支;若产品本身依赖 Service Worker,则应保留一组真实启用它的集成用例,不能长期靠 block 绕开。
APIRequestContext 可用于造数与清理。page.request 与所属 context 共享 cookie jar,而 request.newContext() 是独立 cookie 存储;选择错误会让 API 造数意外继承管理员会话。把服务账号 token 放进独立 request fixture,限制 baseURL 和目标域名,响应附件只保留必要字段。网络失败、HTTP 业务失败和页面断言失败要分开记录,才能知道错误发生在哪一层。
Trace、截图和报告怎样组成失败证据
截图只给一个像素时刻,trace 还包含动作时间线、DOM snapshot、源码、网络、console 和错误。官方Trace Viewer 说明解释了 trace: 'on-first-retry' 的精确语义:首轮不采集,只有失败后的第一次重试会记录;如果 retries 为 0,就不会产生 trace。这个策略节省通过用例的采集成本,却拿不到首轮原始现场。高风险门禁若只需保留首轮失败,可按官方 trace 配置评估 retain-on-first-failure;若每个失败 attempt 都要留证,则评估 retain-on-failure。两者都会增加采集开销和敏感数据副本,启用前先测量;本地复现可临时运行 npx playwright test --trace on。
实际反向实验中,同名按钮用例首轮和重试都失败,runner 为 retry 目录保存了:
attachment #1: screenshot (image/png)
test-results/...-retry1/test-failed-1.png
attachment #3: trace (application/zip)
test-results/...-retry1/trace.zip
Usage:
npx playwright show-trace test-results/...-retry1/trace.zip证据治理要从采集前开始。截图和视频会记录姓名、订单、聊天和验证码;trace 的 DOM、网络 body、console 甚至源码也可能包含 token、内部 URL 与绝对路径。不要把报告发布成匿名可访问静态站点。CI artifact 应使用受控存储、最小下载权限、访问审计和短保留期;高敏项目可在上传前加密,或只上传经过脱敏的结构化摘要,将原始证据留在隔离存储。
建议把 testInfo.attach() 用于经过筛选的业务证据,例如请求 id、订单 id 哈希、服务端 trace id 和环境版本,而不是完整响应。测试数据生成器使用显眼的虚构前缀,页面日志禁止输出 secret。保留策略按结果分层:通过用例通常只留聚合指标,失败和 flaky 留短期 trace,发布阻断缺陷由工单引用受控证据;到期自动删除并验证删除任务。容量预算可用这个模型估算:
日增存储 ≈ 用例数 × project 数 × 预期执行 attempt 数 × 单次证据平均大小 × 采集比例因此 trace: 'on'、video: 'on' 与全量三浏览器相乘后,成本不是“多几个文件”,而是网络上传、artifact 存储、报告解压和排障下载时间一起增长。先测一周失败率和单包分布,再决定保留与采样,不使用脱离项目负载的统一阈值。
Retry 会更换 worker,但不会修复根因
Playwright 在 worker OS 进程中运行测试,每个 worker 启动自己的 browser。官方重试模型说明,测试失败后整个 worker 连同浏览器会被丢弃,新 worker 重新执行 hook;启用 retry 时,失败测试在新 worker 里再跑。这个行为能阻止故障污染后续测试,却也改变了进程内缓存、浏览器状态和 setup 时序。
一次刻意构造的用例在 testInfo.retry === 0 时失败,在 retry 1 时通过,实际汇总为:
1 flaky
[chromium] retry classification exposes a flaky result instead of hiding it
3 passedPlaywright 把结果分成 passed、flaky 和 failed;门禁应保留这一区别。一个实用策略是:新出现的 flaky 阻断或隔离,已知 flaky 必须有 owner、工单、首次出现的版本、失败签名和到期时间;重试率与首轮失败率单独上报,不能只上报最终失败率。若某测试首轮失败率持续上升而最终全绿,系统仍在恶化。
排查 retry 差异时,重点比较两轮的 worker、账号、数据、请求 id、浏览器启动参数和 trace。只有重试通过通常指向未隔离状态、竞争、服务冷启动或随机数据,而不是证明“偶发可接受”。对幂等性差的业务,重试还可能重复扣减、创建或发送消息;测试账号与测试服务必须支持幂等 key 和清理,不能让 runner 的重试语义直接作用于不可逆生产操作。
Shard 提升吞吐,也会放大共享状态
worker 是单机进程并行,shard 是把测试分发到多台 CI 机器。命令格式为 --shard=x/y。启用 fullyParallel: true 时,Playwright 可以按测试级粒度平衡;否则主要按文件分配,大文件会造成尾部 shard 很慢。官方分片说明给出了这两种分配差异。
最小实验把三个独立用例分到两个 shard,实际得到 shard 1 两个用例、shard 2 一个用例,均通过:
npx playwright test --grep-invert retry --shard=1/2 --reporter=line
npx playwright test --grep-invert retry --shard=2/2 --reporter=line分片前先证明数据命名空间独立。不同 shard 会各自启动 worker,索引不能充当跨机器全局 ID,所以唯一前缀应至少包含 CI run id、shard id 和 worker index;账号池则按 shard 划出不重叠区间后,再用 parallelIndex 选择稳定槽位。端口、下载目录、租户和邮件收件箱也要分区。否则 shard 数越多,冲突越频繁,团队又会误以为浏览器并发能力不足。
多 shard 报告不能各自散落。CI 使用 blob reporter,每个 shard 上传独立 blob artifact,再由一个受控作业下载到同一目录并执行:
npx playwright merge-reports --reporter=html ./all-blob-reportsblob 包含测试结果与附件,仍按敏感制品处理。合并作业必须在部分 shard 失败时也运行,并在最终状态中保留缺失 shard;否则“只合并成功作业”会生成一份看似全绿、实际少测一部分的报告。分片扩容的停止信号不是 CPU 未满,而是数据库、账号池、测试服务或 artifact 上传成为瓶颈;用每个 shard 的排队、执行、失败与上传耗时观察瓶颈,再决定增加机器还是减少矩阵。
CI 中固定版本、权限与证据出口
一条可审查的 GitHub Actions 作业可以这样组织:
name: e2e
on:
pull_request:
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --project=chromium --shard=${{ matrix.shard }}/2
env:
E2E_BASE_URL: ${{ vars.E2E_BASE_URL }}
E2E_USER: ${{ secrets.E2E_USER }}
E2E_PASSWORD: ${{ secrets.E2E_PASSWORD }}
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: blob-${{ matrix.shard }}
path: blob-report/
retention-days: 3官方CI 工作流同样采用 npm ci、playwright install --with-deps、测试执行和 artifact 上传顺序。if: !cancelled() 让失败报告也能上传;artifact 名含 shard,避免覆盖。示例中的 Action 主版本仍是会变化的外部依赖,生产仓库应固定经过审查的 commit SHA,并交给依赖更新机器人发起升级评审。仓库权限只给 contents: read,部署 token 与测试凭证不进入 fork PR。外部贡献触发的作业应运行不需要 secret 的匿名用例,可信分支再运行认证矩阵。
也可以使用官方 Playwright 镜像,镜像含浏览器和系统依赖,但不含项目的 Playwright npm 包;镜像标签必须与依赖版本匹配。官方Docker 说明提醒默认 root 会关闭 Chromium sandbox,访问不可信网站时应使用独立用户和 seccomp 配置。E2E 即使访问自家站点,也可能加载第三方广告、附件或用户内容,不能因为“测试环境”就自动视为可信。Chromium 在容器里还需要足够共享内存,--ipc=host 或等价资源配置应通过压测验证,不能遇到崩溃就长期授予 SYS_ADMIN。
浏览器缓存并非总能省时。官方镜像已带二进制时无需再缓存;普通 runner 若缓存,升级 Playwright 后必须失效。CI 日志至少记录 Node、包版本、project、浏览器 revision、操作系统镜像、worker/shard 和目标环境版本。它们是复现键,也是判断“代码回归”还是“运行环境变化”的依据。
项目接入从一条关键路径开始
接入现有仓库时,先选一条高价值且可清理的路径,例如“登录测试租户,创建草稿订单,保存后从列表读取,再删除”。不要第一批就录制几十条脆弱点击。先建立目录和责任:
tests/e2e/
├─ auth.setup.ts
├─ fixtures.ts
├─ pages/
│ └─ order-page.ts
├─ orders/
│ └─ create-order.spec.ts
└─ smoke/
└─ health.spec.ts
playwright/.auth/ # ignored, job 内生成
test-results/ # ignored,失败制品
playwright-report/ # ignored,本地报告
playwright.config.tsPage Object 只封装稳定页面语言与复用动作,例如“保存订单”和“读取状态”,不要把所有断言、fixture、随机数据与网络 mock 藏进一个巨型类。测试标题表达业务结果,step 表达可观察阶段。一个失败应能从标题定位 owner,从 trace 定位技术节点,从请求 id 关联服务端日志。
逐步放量比一次全开更可靠:先在本地和 PR 上运行单 Chromium smoke;稳定后加入写路径与隔离账号;再增加 Firefox/WebKit 关键流;最后根据风险加入认证角色、语言时区和移动设备。每次扩矩阵都同时测执行时间、首轮失败率、证据大小、账号占用和清理残留。Playwright 适合现代 Web E2E、组件边界外的用户流程和浏览器协议验证;纯函数规则应留给单元测试,后端契约应由 API/契约测试更快地覆盖。用浏览器重复证明所有排列组合,会得到最慢、最贵且最难定位的测试金字塔。
从错误文本反推故障层
浏览器无法启动时,先运行 npx playwright --version 与 npx playwright install --dry-run 查看目标 revision,再检查可执行文件和系统库。Linux 的 Host system is missing dependencies 指向 OS 包;容器中的 Target page, context or browser has been closed 还要结合 kernel OOM、共享内存和进程退出码。企业代理下载报自签名证书错误,应安装受信根证书,不要关闭 TLS 校验。
页面导航超时分三层看:webServer 是否真正 ready,浏览器 Network 是否到达正确 baseURL,服务端是否返回。ERR_NAME_NOT_RESOLVED 是 DNS/容器网络;ERR_CONNECTION_REFUSED 是地址或监听;HTTP 401/403 是身份或权限;200 但页面空白则查 console、JS bundle 和 hydration。把这些错误都改成 page.goto(..., { waitUntil: 'domcontentloaded' }) 只会移动检测点。
定位失败看 call log 和 DOM snapshot。0 个元素通常是路由、frame、权限或渲染条件;多个元素是语义歧义;元素存在但不可点击则看 actionability。跨 iframe 要用 frameLocator,新标签页要在触发动作前注册 context.waitForEvent('page'),下载要先等待 download 事件。事件监听顺序错了会造成真正的竞态,固定 sleep 只是提高碰巧成功的概率。
网络 mock 不命中时,检查 URL glob、注册时机、请求所在 context、Service Worker 和重定向。认证用例本地通过、CI 401 时,检查 setup project 是否被依赖、state 文件路径是否以配置目录解析、cookie domain/secure/sameSite 是否匹配目标 URL,以及登录是否触发了浏览器专属绑定。不要把过期 state 当成 locator 故障。
并发后才失败时,先将 --workers=1 作为诊断实验:若串行稳定,不代表应永久单线程,而是提示共享账号、数据、端口或限流。比较 worker/shard 标识、资源 owner 和服务端冲突响应。若单线程也随机失败,再检查外部依赖、动画、随机种子和环境资源。诊断开关应该帮助分型,不能成为永久隐藏容量问题的配置。
清理、回滚和退出要能真正执行
本地撤除 Playwright 时,先确认没有其他 package 依赖它,再删除测试代码、配置和生成物。浏览器缓存可能被多个仓库共享,不能在一个项目的普通 clean 脚本中递归删除整个用户缓存。项目级清理可以这样做:
# 仅清理当前仓库产生的非源码制品
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue `
test-results, playwright-report, blob-report, playwright/.auth
# 查看该版本认识的浏览器与安装位置,再决定是否卸载
npx playwright install --list
npx playwright uninstall
# 确认依赖关系后再移除包
npm uninstall --save-dev @playwright/testplaywright uninstall 处理当前 Playwright 安装管理的浏览器;共享机器执行前要确认其他项目的版本和缓存策略。CI 回滚则恢复 lockfile、配置与镜像标签三者,重新安装匹配 revision,并跑一条正向、一条反向用例。只降 npm 包而复用新浏览器缓存,仍可能得到不可解释结果。
测试数据退出比删脚本更重要。停用账号要撤销 token、删除 storage state、清理测试租户与对象、关闭 artifact 公开链接,并检查日志和对象存储的保留任务。第三方测试云还要撤销 access key、删除 tunnel、导出必要审计后终止项目。退出完成的证据是凭证不可再用、残留数据为零或有明确例外、CI 不再下载浏览器与上传制品,而不是目录已经删除。
长期治理看趋势、所有权和升级演练
稳定的 Playwright 平台至少有三类 owner:应用团队拥有业务断言与测试数据,测试基础设施 owner 维护版本、镜像、reporter 和 CI 模板,安全或数据 owner 审批账号权限、证据字段与保留策略。任何 quarantine 都要有具体 owner 和到期条件;无人负责的 flaky 用例会逐渐变成永久跳过。
仪表盘不要只看“最终通过率”。至少分开观察首轮通过率、flaky 数、最终失败率、P50/P95 执行时长、各 project 差异、每个失败签名、证据字节数、账号池等待和清理残留。阈值由项目 SLO 和历史基线决定,但有几个不变量可以直接用:同一用例扩大 worker 后不能产生更多共享数据冲突;作业结束后临时资源回到稳定基线;报告中的测试数必须等于计划矩阵;失败证据必须能关联到代码版本、环境版本和服务端请求。
升级 Playwright 时创建独立分支,同时更新 npm 依赖、浏览器 revision 与 CI 镜像。先跑安装启动、关键 locator、认证 state、下载上传、网络 mock、trace 打开和跨浏览器 smoke,再比较首轮失败率、耗时与 artifact 大小。新版本通过并不表示可以立即删除旧基线;保留一个短回滚窗口,确认共享 runner 缓存和所有 shard 都已切换后再清理旧浏览器。
选择是否继续扩大 Playwright 投入时,问的是风险与证据是否匹配。用户关键路径、跨浏览器行为、真实登录与前端集成值得浏览器级验证;大量业务组合、后端边界和算法规则应下沉到更快的测试层。最后形成的门禁应当让一个新人只凭失败输出和受控制品,就能回答哪个 project、哪个 worker、哪份状态、哪个请求、哪一步断言失败,以及数据是否已经清理。做到这一点,流水线的绿色才是一份可以解释、可以复查、也可以回滚的工程证据。
