UI 自动化:UI 自动化中的等待机制设计

如果要只挑一个最容易把 UI 自动化拖垮的问题,更可能选等待机制。因为很多脚本不是不能执行,而是执行结果随机。

最典型的症状是:

  • 本地能过,CI 上偶尔失败
  • 同一条脚本上午过、下午挂
  • 多跑几次又好了

这种问题看起来像“环境抖动”,但真实根因很多时候是:脚本根本没有定义清楚“什么时候页面真的准备好了”。

所以等待机制不是语法细节,而是稳定性设计。

一、我为什么把等待看成状态建模,而不是时间控制

把等待写成固定时间时,本质上是在赌时间:

  • 点完按钮,sleep(2)
  • 提交后,sleep(5)
  • 跳转后,sleep(1)

这种方式最大的问题不是慢,而是它根本没有表达页面状态。你不知道:

  • 页面是否已经可交互
  • 表格是否真的渲染完成
  • 提交后到底是业务完成了,还是只是 toast 先出来了

所以更愿意把等待理解成一组状态判断:

  • 页面就绪
  • 组件就绪
  • 数据稳定
  • 业务完成

二、在项目里怎么拆等待层次

真实项目里,等待不该只停留在“等元素出现”,而更适合拆成四层。

1. DOM 层等待

比如:

  • 元素存在
  • 元素可见
  • 元素可点击

这一层只回答:节点能不能被操作。

2. 组件层等待

比如:

  • 下拉框选项加载完成
  • 表格停止 loading
  • 弹窗动画结束

这一层回答:组件是否进入稳定交互状态。

3. 页面层等待

比如:

  • 页面主区域渲染完成
  • 关键数据区域不再是 skeleton
  • 路由跳转真正完成

这一层回答:用户是否已经“到了正确页面”。

4. 业务层等待

比如:

  • 新建任务真的出现在列表中
  • 审核状态从“待处理”变为“已通过”
  • 异步报表状态从“生成中”切到“已完成”

这一层才是真正能决定测试结论的部分。

很多脚本之所以脆,就是因为只做了第 1 层等待,然后直接去做第 4 层断言。

三、不同工具下,怎么实现等待

Playwright

我最常用的是:

  • locator.waitFor()
  • Playwright assertion 自带重试
  • 组合条件等待

例如不是只等按钮出现,而是等:

  • 按钮出现
  • loading 消失
  • 列表目标行出现

Selenium

会统一封装:

  • WebDriverWait
  • expected conditions
  • 自定义 lambda 条件

因为 Selenium 如果不统一等待层,很容易在各个页面对象里散落大量等待碎片。

chromedp

会封装:

  • 节点可见
  • 文本出现
  • 某个 JS 条件返回 true

chromedp 更底层,所以更需要你自己把等待抽象成公共能力。

四、一个真实页面为什么不能只等 loading 消失

很多后台页面都有这个特点:

  1. 页面骨架先渲染
  2. 列表 loading 消失
  3. 但真正的数据表格还在二次渲染
  4. 按钮样式已经出现,但点击后会被遮罩拦截

如果脚本只写“等待 loading 消失”,你会得到很多随机失败:

  • 元素看起来存在,实际不能点击
  • 表格刚出来,但数据还是旧的
  • toast 已弹,列表还没更新

所以等待机制必须比“loading 没了”更贴近业务状态。

五、我在真实项目里最常遇到的等待场景

1. 搜索表格结果刷新

这类页面通常会经历:

  • 点击搜索
  • 请求发出
  • 表格 loading
  • 数据替换

这里不该只等按钮点击完成,而更该等:

  • loading 消失
  • 结果总数变化或目标行出现

2. 提交表单后的状态切换

很多页面提交后先弹出“成功”,再异步更新列表。如果只断 toast,很容易误判。

更稳妥的做法是:

  1. 等 toast 出现
  2. 等 toast 消失
  3. 等列表页完成刷新
  4. 等目标状态出现

3. 异步任务轮询

比如导出报表、任务执行、审批流同步。这类场景用固定 sleep 基本是最差方案。

更稳妥的做法是轮询等待:

  • 每隔 2 秒检查一次状态
  • 超过总超时直接失败
  • 失败时保存当前状态和最近轮询结果

六、怎么避免等待逻辑污染业务 case

更稳妥的约束是让等待逻辑只放在三种地方:

  • 页面对象
  • 组件对象
  • waits 公共模块

业务 case 里尽量不出现大量原始等待代码。

例如:

1
2
3
await taskListPage.waitLoaded();
await taskListPage.search(taskName);
await taskListPage.waitRowVisible(taskName);

而不是:

1
2
3
await page.waitForSelector(...);
await page.waitForTimeout(2000);
await expect(page.locator(...)).toBeVisible();

后者短期能跑,长期会越来越散。

七、一个更像实战的失败例子

我遇到过一个后台任务页,提交成功后会立即弹 toast,但列表刷新依赖另一个异步接口。最早脚本只写:

  1. 点击提交
  2. 等 toast 成功
  3. 断言列表出现任务

结果在 CI 上经常失败,因为 toast 比列表刷新快很多。

后来改成:

  1. 点击提交
  2. 等成功 toast 出现
  3. 等表格 loading 完整走完一轮
  4. 等目标任务名出现
  5. 如 10 秒内未出现,再抓当前表格首屏文本和截图

这一改,稳定性明显上升,而且失败后能更快分辨:

  • 是任务没创建成功
  • 还是列表刷新慢
  • 还是脚本定位错了

八、等待机制和执行速度怎么平衡

等待写深了,常常会带来“执行会很慢”的担心。更常见的情况是,真正拖慢执行的通常不是等待本身,而是无效等待。

更合理的做法是:

  • 默认短超时
  • 对历史高波动页面单独放宽
  • 把长轮询只保留给少量高价值场景
  • smoke 任务只保留强信号等待

也就是说,不是所有页面都要跑最重的等待策略,而是按风险分层。

九、怎么判断等待设计是不是做对了

我主要看这些信号:

  • 同一条脚本的随机失败是否明显下降
  • CI 和本地执行差异是否缩小
  • 失败后是否更容易定位到是数据慢、页面慢还是业务错
  • 页面行为变更时,是否更多改动集中在页面对象层

如果这些没改善,说明等待设计大概率还停留在“换了一种写 sleep 的方式”。

十、结语

UI 自动化中的等待机制,本质上是在定义页面何时真正进入“可继续执行”的状态。它不是简单的时间控制,而是一套稳定状态建模能力。只有把 DOM、组件、页面、业务四层等待边界都理清,UI 自动化才会从“偶尔能跑”变成“可以长期信任”。

本章延伸阅读