Playwright 从入门到实战:我把 UI 自动化稳定性从 60% 提到 95%

举报
霍格沃兹测试开发学社 发表于 2026/09/22 13:02:04 2026/09/22
【摘要】 去年下半年,我们团队的 Selenium 测试套件遇到了一个诡异的问题:同样的代码,在 Chrome 89 上跑得好好的,Chrome 90 发布后突然开始随机失败。排查了整整两天,最后发现是浏览器更新后元素点击行为的细微差异导致的。这种“浏览器一更新,测试就挂”的情况,我们每个月至少遇到三五次。那段时间,CI 报告上永远是红红绿绿一片。团队里流传一句话:“别管测试结果,看运气。”说实话,这...

去年下半年,我们团队的 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'quantity2 }
  });
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({ timeout15000 });

这对于验证一些“最终一致性”的场景特别有用,比如异步任务的状态更新。

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 次。

指标
Selenium
Playwright
平均稳定性
62%
96%
平均执行时间(单用例)
18s
6s
因定位器失效导致的失败
约 40%
约 5%
排查一个失败的平均耗时
30-60 分钟
5-10 分钟

整体套件的执行时间从 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 删掉,看看会怎样。很多时候,光是这一步就能带来明显的改善。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。