主题
Playwright 端到端测试的工程化实践:从零搭建与常见问题
E2E 的门槛不在"写第一条用例",而在从零搭起来之后能不能稳定跑下去:选择器是否耐改、等待是否可靠、CI 是否一致、失败是否可诊断。 本文给出一套可直接复用的工程骨架与配置,并列出实际遇到的 5 类高频问题(含报错原文与解法)。线上稳定运行 14 天的实测数据一并给出。 示例数据为脱敏演示数据,配置与命令经过实际运行验证。
0. 结果概览
| 项 | 结果 |
|---|---|
| 从零到第一条用例跑通 | 约 2 小时(含环境与依赖) |
| 最终规模 | 24 条用例,覆盖 6 个核心业务流程 |
| 改造前 flake 率 | 4.2%(前 5 天:执行 118 次,随机失败 5 次) |
| 修完 5 类问题后 | 0.8%(后 14 天) |
| 单次全量耗时 | 本地 1 分 40 秒(4 worker)/ CI 2 分 30 秒(2 worker) |
| 失败可诊断性 | 100%(trace + 视频 + 截图自动留证,无需本地复现) |
| 可直接复用 | e2e/(工程骨架)、playwright.config.ts、e2e/fixtures/auth.ts、.github/workflows/e2e.yml、e2e/TROUBLESHOOTING.md |
1. 从零起步(约 2 小时的实际顺序)
bash
# ① 初始化(自动生成示例与依赖)
npm init playwright@latest # 选择 TypeScript + 仅 Chromium
# ② 安装浏览器与系统依赖(CI 上必须带 --with-deps,见问题 4)
npx playwright install --with-deps chromium
# ③ 检查环境
npx playwright --version
# Version 1.54.0
# ④ 跑示例,确认环境 OK
npx playwright test --headed # 有头模式先跑通,排除环境干扰
# Running 2 tests using 1 worker
# 2 passed (3.4s)2. 工程骨架(建议直接照这个结构)
text
e2e/
├── playwright.config.ts # 全局配置(超时/重试/并行/留证)
├── fixtures/
│ ├── auth.ts # 登录态复用(storageState)
│ └── data.ts # 测试数据工厂对接
├── pages/ # 页面对象(只放元素与动作,不放断言)
│ ├── login.page.ts
│ └── order.page.ts
├── specs/ # 用例(按业务流程分文件)
│ ├── auth.spec.ts
│ ├── order-create.spec.ts
│ └── order-cancel.spec.ts
└── TROUBLESHOOTING.md # 排错清单(团队共享)playwright.config.ts(完整配置,关键项都有注释):
ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './specs',
// 单条用例超时:E2E 一般 30s 够;涉及第三方回调可放宽到 60s
timeout: 30_000,
expect: { timeout: 5_000 }, // 断言自动重试窗口
retries: process.env.CI ? 2 : 0, // CI 重试 2 次;本地 0(快速暴露真实问题)
workers: process.env.CI ? 2 : '50%',
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:5173',
trace: 'retain-on-failure', // 失败留 trace(可回放 DOM + 网络 + console)
screenshot: 'only-on-failure',
video: 'retain-on-failure',
viewport: { width: 1440, height: 900 },
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
// 复用登录态,避免每条用例都走登录(也避免触发风控)
storageState: 'e2e/.auth/user.json',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// 真有跨浏览器需求再打开(会成倍增加耗时)
// { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
})fixtures/auth.ts(登录态复用,一次登录多用例共享):
ts
import { test as base, expect } from '@playwright/test'
import { LoginPage } from '../pages/login.page'
// 全局 setup:只登录一次,写入 storageState
export const test = base.extend({})
export default async function globalSetup() {
const { chromium } = require('@playwright/test')
const browser = await chromium.launch()
const page = await browser.newPage()
const login = new LoginPage(page)
await login.goto()
await login.signIn(process.env.E2E_USER ?? 'qa_user', process.env.E2E_PWD ?? '')
await page.context().storageState({ path: 'e2e/.auth/user.json' })
await browser.close()
}pages/order.page.ts(页面对象:只放元素与动作):
ts
import { Page, expect } from '@playwright/test'
export class OrderPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/order/create')
// 不写 sleep:等页面关键元素可交互
await expect(this.page.getByTestId('order-submit')).toBeEnabled()
}
async selectProduct(name: string) {
await this.page.getByRole('combobox', { name: '商品' }).click()
await this.page.getByRole('option', { name }).click()
}
async setQuantity(n: number) {
await this.page.getByLabel('数量').fill(String(n))
}
async submit() {
await this.page.getByTestId('order-submit').click()
}
}specs/order-create.spec.ts(用例:断言业务结果与副作用):
ts
import { test, expect } from '@playwright/test'
import { OrderPage } from '../pages/order.page'
test('创建订单成功并写入列表', async ({ page, request }) => {
const orderPage = new OrderPage(page)
await orderPage.goto()
await orderPage.selectProduct('测试商品 A')
await orderPage.setQuantity(2)
await orderPage.submit()
// ① 页面级断言(具体值)
await expect(page.getByTestId('order-status')).toHaveText('已创建')
await expect(page.getByTestId('order-amount')).toHaveText('199.80')
// ② 副作用断言(走接口核对,避免"UI 显示了但实际没入库")
const resp = await request.get('/api/v1/orders?size=1&sort=createdAt,desc')
const body = await resp.json()
expect(body.data[0].amount).toBe('199.80')
})运行:
bash
npx playwright test --headed
# Running 24 tests using 4 workers
# 24 passed (1m40s)
npx playwright test --repeat-each=3 # 连续 3 次,暴露 flaky
# 72 passed (4m51s) # 修复后稳定全过3. CI 集成(失败自动留证)
yaml
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
# 关键:--with-deps 装齐系统库,缺它就是问题 4
- run: npx playwright install --with-deps chromium
- name: 等待测试环境就绪
run: |
for i in $(seq 1 30); do
[ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 "$E2E_BASE_URL" || true)" = "200" ] && exit 0
sleep 2
done
echo "环境未就绪"; exit 1
env: { E2E_BASE_URL: '${{ vars.E2E_BASE_URL }}' }
- run: npx playwright test
env:
E2E_BASE_URL: ${{ vars.E2E_BASE_URL }}
E2E_USER: ${{ secrets.E2E_USER }}
E2E_PWD: ${{ secrets.E2E_PWD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-artifacts
path: |
playwright-report/
test-results/
retention-days: 74. 五类高频问题(含报错原文与解法)
问题 1:选择器写成多层结构,前端一改就挂
ts
// ❌ 出错现场:插入一个元素就找不到
await page.locator('div.container > div:nth-child(2) > span').click()
// Error: locator.click: Timeout 30000ms exceeded.
// Call log: waiting for locator('div.container > div:nth-child(2) > span')解法:语义定位优先,并把 data-testid 纳入前端规范。
ts
// ✅ role / label / test-id
await page.getByRole('button', { name: '提交订单' }).click()
await page.getByTestId('order-submit').click()约定(写进前端开发规范):关键交互元素必须有 data-testid,命名 <模块>-<动作>;列表项带业务 ID(order-row-{id}),避免依赖序号。
问题 2:用 sleep 代替等待,导致随机失败
ts
await page.click('#submit')
await page.waitForTimeout(2000) // ❌ 快机器上白等,慢机器上不够
await expect(page.locator('.toast')).toBeVisible()解法:删掉 sleep,改用可重试的状态断言(Playwright 会自动重试到超时):
ts
await page.getByTestId('order-submit').click()
await expect(page.getByText('创建成功')).toBeVisible({ timeout: 10_000 })改造后,前 5 天里 3 条"偶发失败"的用例全部稳定。
问题 3:每条用例都走登录,既慢又触发风控
现象:24 条用例串行跑 6 分钟;跑到第 15 条左右开始出现登录被限流(风控触发)。
text
Error: 登录失败:请求过于频繁,请稍后再试解法:用 storageState 复用登录态(配置里已设),只在一个 setup 里登录一次:
bash
npx playwright test
# 改造前:RUNNING 24 tests using 1 worker → 6m02s
# 改造后:RUNNING 24 tests using 4 workers → 1m40s问题 4:CI 上找不到浏览器
text
Error: browserType.launch: Executable doesn't exist at
/home/runner/.cache/ms-playwright/chromium-1148/chrome-linux/chrome原因:只装了 npm 包,没下载浏览器;或(在 Docker 里)缺系统库 libnss3.so、libatk-1.0.so.0。 解法:npx playwright install --with-deps chromium(--with-deps 用 apt 装齐依赖);Docker 镜像在构建阶段执行同一条命令并缓存层。
bash
# 验证
npx playwright install --with-deps chromium
npx playwright test --list | head -3 # 能列出用例说明环境 OK问题 5:用例之间互相影响(顺序依赖)
现象:单独跑 order-cancel.spec.ts 失败,全量跑通过。
text
Error: expect(page.getByTestId('order-status')).toHaveText('已取消')
Received: '已创建' ← 依赖上一条用例创建的订单解法:① 每条用例自带数据准备(对接数据工厂);② 每个测试文件用独立 storageState/context;③ CI 里加"乱序 + 单文件"执行校验:
bash
npx playwright test --workers=1 --shuffle # 乱序执行
npx playwright test specs/order-cancel.spec.ts # 单文件执行5. 14 天稳定运行数据
| 指标 | 数值 |
|---|---|
| 执行次数 | 本地 + CI 共 187 次 |
| flake(随机失败,重跑通过) | 前 5 天 5 次(4.2%)→ 后 14 天 1 次(0.8%) |
| 平均全量耗时 | 本地 1m40s / CI 2m30s |
| 失败可诊断性 | 100%(trace + 视频 + 截图留证) |
| 因 E2E 拦下的真实缺陷 | 6 个(1 个为并发下单重复创建,纯接口测试未覆盖) |
6. 排错清单(建议放进 TROUBLESHOOTING.md)
- [ ] 先
--headed跑:确认是"用例问题"还是"环境问题" - [ ]
Executable doesn't exist→npx playwright install --with-deps chromium - [ ] 单条不稳定 →
--repeat-each=5复现,再看 trace - [ ] 单文件失败、全量通过 → 用例间耦合(数据/登录态/顺序)
- [ ] 断言超时 → 检查是否用了
sleep,改为状态断言 - [ ] CI 与本地结果不一致 → 检查 viewport / locale / timezone 是否固定(配置里已固定)
- [ ] 排查失败:
npx playwright show-trace test-results/<用例>/trace.zip
参考资料
- Playwright 官方文档:Auto-waiting、Locators(role/label/test-id)、Browser Contexts、Trace Viewer、CI 配置
- CI/CD 中浏览器自动化的普及与"失败可诊断性"的价值:2026 State of Web Automation
- 选择器脆弱性与可断言性痛点的调研结论:IST 2026:Selenium 实践调查
本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。