Playwright 从入门到实战:我把 UI 自动化稳定性从 60% 提到 95%
去年下半年,我们团队的 Selenium 测试套件遇到了一个诡异的问题:同样的代码,在 Chrome 89 上跑得好好的,Chrome 90 发布后突然开始随机失败。排查了整整两天,最后发现是浏览器更新后元素点击行为的细微差异导致的。这种“浏览器一更新,测试就挂”的情况,我们每个月至少遇到三五次。
那段时间,CI 报告上永远是红红绿绿一片。团队里流传一句话:“别管测试结果,看运气。”说实话,这话听着就让人难受。
后来我决定动手改造。引入 Playwright 之后,花了大概两个月时间,把核心场景的稳定性从 60% 左右提到了 95% 以上。这个过程踩了不少坑,也积累了一些真实有用的经验,今天分享出来,希望对同样在跟 flaky test 斗争的朋友有所帮助。
一、为什么 Selenium 的稳定性问题这么难治?
Selenium 的工作原理是:你的代码 → WebDriver 协议 → 浏览器驱动 → 浏览器。多了一层协议翻译,天然增加了不确定性和延迟。
更关键的是,Selenium 本身不负责等待。你必须自己处理元素什么时候出现、什么时候可点击、什么时候加载完。于是大家的代码里充斥着这样的写法:
time.sleep(3) # 祈祷元素已经加载完了
或者更“专业”一点的显式等待:
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "dynamic-element"))
)
element.click() # 等等,元素是出现了,但真的可点击吗?
presence_of_element_located 只保证元素存在于 DOM 中,不保证可见、不保证可用、不保证不被遮挡。所以你还需要判断可见性、可点击性……一套下来,等待代码比业务代码还长。
而 Playwright 的思路完全不同——它直接通过 DevTools 协议与浏览器通信,没有中间商赚差价,而且把等待逻辑内建到了每一个操作里。
二、真正让稳定性上来的几个关键动作
1. 别再用 time.sleep 了,Playwright 会自己等
这是提升稳定性最立竿见影的一步。
Playwright 在执行 click()、fill() 等操作之前,会自动做一系列检查——元素存在于 DOM 中、元素可见、元素启用、元素稳定(不在动画中)、元素没有被遮挡。只有全部通过,才会真正执行操作。
也就是说,你写:
await page.getByRole('button', { name: '提交' }).click();
Playwright 内部会自动等这个按钮变得可点击,如果 30 秒内还没准备好才会报错。你完全不需要自己写任何等待逻辑。
我们迁移后的第一个版本,光是删掉那些零散的 sleep 和显式等待,测试的假失败率就降了将近一半。
但要注意一点:自动等待解决的是“元素能不能操作”的问题,不解决“数据有没有返回”的问题。比如你点击了“查询”按钮,按钮是可点击的,但后端数据还没回来,页面上还是空列表。这种情况需要显式等待:
// 等待接口返回后再断言
await page.waitForResponse(resp =>
resp.url().includes('/api/orders') && resp.status() === 200
);
await expect(page.getByTestId('order-list')).toBeVisible();
核心原则:用明确的信号同步,而不是靠猜时间。等待网络请求完成、等待元素出现、等待 URL 变化,都比 sleep 靠谱一百倍。
2. 定位器选对了,测试就稳了一大半
我们之前 Selenium 的定位器大量使用 CSS 和 XPath,比如 .btn-primary > div:nth-child(2) > span。这种选择器只要前端调整一下 DOM 结构就废了。
Playwright 推荐优先使用面向用户的属性来定位。官方给出了明确的优先级:
第一优先级:getByRole
await page.getByRole('button', { name: '登录' }).click();
按 ARIA role 和可访问名称定位,反映的是用户和辅助技术看到的东西,不依赖 DOM 结构,前端怎么重构都不影响。
第二优先级:getByLabel / getByPlaceholder
await page.getByLabel('用户名').fill('testuser');
await page.getByPlaceholder('请输入搜索关键词').fill('Playwright');
第三优先级:getByTestId
await page.getByTestId('login-submit').click();
这个需要团队协作,要求前端在核心元素上加 data-testid 属性。一开始推可能会有些阻力,但一旦形成规范,收益非常大——它是代码和测试之间最稳定的契约。
我们团队的做法是在 PR 检查中要求核心 UI 元素必须加测试 ID,配合 lint 规则 enforce。推行了两周之后,大家就意识到好处了——测试用例再也不会因为前端改了个 class 名就挂掉。
3. 登录态复用,别每个用例都重新登录
这是个容易被忽略但影响巨大的问题。之前我们的测试用例,每一条都从打开登录页开始,输入用户名密码,点击登录,等跳转……光是登录就要十几秒,而且每一步都是潜在的失败点。
Playwright 的 storageState 机制可以完美解决这个问题:
// 全局 setup,只执行一次
// auth.setup.ts
import { test as setup } from'@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('密码').fill('password');
await page.getByRole('button', { name: '登录' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'auth.json' });
});
然后在 playwright.config.ts 里配置:
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'auth.json',
},
dependencies: ['setup'],
},
],
});
这样每个测试启动时就已经处于登录状态,又快又稳。我们迁移后,单个用例的平均执行时间从 18 秒降到了 6 秒左右。
4. 测试数据用 API 准备,别走 UI
这也是一个大坑。很多测试的数据准备是通过 UI 操作的——比如先通过页面创建一个订单,然后再验证订单列表。
问题在于,UI 操作本身就是不稳定的来源。页面加载、表单验证、提交响应……每一步都可能出问题。
正确的做法是:数据准备和清理走 API,UI 只负责验证展示和交互。
test('订单列表展示', async ({ page, request }) => {
// 用 API 创建测试数据
const response = await request.post('/api/test/orders', {
data: { product: 'test-product', quantity: 2 }
});
const order = await response.json();
// UI 只验证展示
await page.goto('/orders');
await expect(page.getByText(order.id)).toBeVisible();
});
我们后来专门建了一个 /api/test/* 的命名空间,只在 CI 环境开启,用来准备和清理测试数据。这一步做完之后,测试之间的数据污染问题基本消失了。
5. 重试要合理用,但不能当万能药
Playwright 内置了测试重试机制,在配置文件中一行就能开启:
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});
开启后,失败的测试会自动重试。Playwright 会把测试分为三类:passed(第一次就通过)、flaky(第一次失败但重试通过)、failed(重试也没通过)。
但是,重试不是用来掩盖问题的。如果一个测试 40% 的概率失败,那不叫 flaky,那叫有 bug。我们的原则是:重试只是给真正的偶发问题一个兜底,如果某个用例频繁进入 flaky 列表,必须去查根因。
另外,在断言层面,expect.toPass() 也很有用。它会在超时时间内反复执行断言回调,直到条件满足:
await expect(async () => {
const response = await page.request.get('/api/status');
expect(response.status()).toBe(200);
}).toPass({ timeout: 15000 });
这对于验证一些“最终一致性”的场景特别有用,比如异步任务的状态更新。
6. 失败时要有足够的信息来排查
这一点可能是最容易被忽视的。以前我们的测试挂了,CI 上只显示一个红色的叉,什么信息都没有。要排查就得在本地重现,有时候根本重现不了。
Playwright 的 Trace Viewer 彻底改变了这个局面。配置一下:
export default defineConfig({
use: {
trace: 'retain-on-failure-and-retries',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
测试失败后,你会得到一个 trace.zip 文件,用 npx playwright show-trace trace.zip 打开,可以看到每一步操作的 DOM 快照、网络请求、控制台日志、截图……基本上等于给测试录了个“行车记录仪”。
我们最常遇到的场景是:测试在 CI 上挂了,但在本地怎么跑都过。有了 Trace 之后,直接看失败那一刻的页面状态,很多时候一眼就能看出问题——比如某个弹窗遮挡了按钮、某个接口返回了 500、页面还停留在 loading 状态等等。
Trace 只在失败时保留,不会拖慢正常通过的测试。
三、迁移后的真实数据
说了这么多方法,最终效果怎么样?
我们做了一次对比:挑 10 个核心业务流程,分别用 Selenium 和 Playwright 各跑 50 次。
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
整体套件的执行时间从 45 分钟降到了 18 分钟左右,随机失败率从 12% 降到了 2% 以下。当然,这个数据跟具体的业务场景和技术栈有关,不一定能完全复现,但方向是对的。
更有意思的是团队氛围的变化。以前发版前大家都很紧张,盯着 CI 报告刷来刷去。现在大部分时候测试报告都是绿的,偶尔红了也能快速定位问题。这种“心里有底”的感觉,比数字本身更有价值。
四、几个我踩过的坑
别在 beforeAll 里做太重的事情。 Playwright 每个测试文件是独立 worker 进程运行的,beforeAll 在每个 worker 里都会跑一次。如果里面做了耗时的数据准备,会严重拖慢整体速度。
视觉回归测试别贪多。 一开始我们想对所有页面都做截图对比,结果动态内容(时间戳、广告位、随机推荐)产生了大量无意义的 diff。后来改成只对核心页面做,并对动态区域设置 mask,才变得可用。
并发数不是越高越好。 默认情况下 Playwright 使用 CPU 核心数的一半作为 worker 数量。盲目调高会导致资源竞争,反而变慢。我们的经验是先 profile 找出瓶颈,再逐步调整。
别把所有用例都塞进每次 CI。 我们用标签把用例分成了 @smoke 和 @full 两组:冒烟测试每次提交都跑,全量回归放在夜间。这样既保证了快速反馈,又覆盖了全量场景。
写在最后
从 60% 到 95%,不是靠某一个“银弹”功能实现的,而是把自动等待、稳定定位、状态复用、API 准备数据、合理重试、Trace 调试这几件事组合在一起,一步步做出来的。
Playwright 给我的感觉是:它把很多以前需要你自己操心的事情,变成了框架的内建能力。你不需要再纠结“到底要等几秒”,不需要再写一堆 ExpectedConditions,不需要再靠日志猜失败原因。
如果你现在的 UI 自动化还在 60% 左右的稳定性上挣扎,我建议先从一个最简单的动作开始——把测试代码里所有的 sleep 删掉,看看会怎样。很多时候,光是这一步就能带来明显的改善。
- 点赞
- 收藏
- 关注作者
评论(0)