【skills4】UI 自动化不脆弱了:七层定位降级链,让 Playwright 脚本学会自救

UI 自动化不脆弱了:七层定位降级链,让 Playwright 脚本学会自救

大家好,我是测试员周周。

上一篇文章讲了接口自动化的自愈——5 类策略,401 刷新 token、500 退避重试,让接口测试脚本不再因为环境波动而白白挂掉。

但接口自愈相对简单,因为 HTTP 协议给了你清晰的信号:状态码告诉你"是什么错了",响应体告诉你"为什么错了"。

UI 自动化可没这么仁慈。

一个page.click('#login-btn')失败了,Playwright 给你的错误信息是:"Timeout 30000ms exceeded. waiting for selector '#login-btn'"。你只知道它找不到这个按钮,但你不知道:是按钮被改了 id?是被 CSS 隐藏了?是页面加载太慢?还是整个页面结构都重构了?

而且 UI 自动化的维护成本是出了名的高。一个版本迭代,前端改了十个组件的 CSS 类名,你的回归脚本能绿三分之一就算运气好。

这篇文章,我就讲讲怎么让 UI 自动化学会"自救"——不是等到挂了才修,而是在运行时动态调整策略。


传统写法为什么脆弱

我们先看一个典型的 Playwright 写法:

await page.fill('#username-input', 'admin') await page.fill('#password-input', 'password123') await page.click('#login-btn') await page.wait_for_selector('#dashboard')

这段代码看起来很直观,但它有一个致命的假设:页面上的元素会永远保持它现在的样子。

现实是什么?

  • 前端重构了组件库,#username-input改成了[data-testid="username"]
  • 样式升级了 CSS 框架,id 变了、class 变了、DOM 层级也变了
  • A/B 实验开始了,50% 的用户看到的是 V2 版本的页面
  • 国际化改版了,按钮文字从"登录"变成了"Sign In"

你的text=登录定位器就失效了。

传统的做法是:人发现脚本挂了,人改定位器,人提 PR。每天花 10 分钟修定位器,一个月就是 3 个多小时。而前端一年要改几十次,这些时间乘以整个团队的自动化维护工作量,相当可观。

我的思路很简单:既然前端改了,那后端代码(测试脚本)应该能自动适应。不是让人去猜新的选择器是什么,而是让系统尝试不同的定位策略,直到找到那个元素为止。


七层定位降级链

这套系统里,UI 自愈的核心是一个七层降级链。

当 Playwright 执行一个定位操作时(点击、输入、选择),不是只试一种策略,而是按照优先级依次尝试七种:

第一层:data-testid(最推荐)。

前端在关键交互元素上埋了data-testid属性。这是最稳定的策略,因为 testid 是专门给测试用的,前端改 UI 但不会改 testid。但前提是团队有这个规范和执行力。

page.get_by_test_id('login-button')

第二层:role + name。

根据元素的语义角色和可访问名称来定位。比如role=button, name=登录。这依赖于前端对无障碍规范的遵守程度。

page.get_by_role('button', name='登录')

第三层:label。

用 label 关联的 for 属性定位输入框。只适用于表单控件。

page.get_by_label('用户名')

第四层:placeholder。

用输入框的占位文本定位。适合没有 label 但有 placeholder 的控件。

page.get_by_placeholder('请输入用户名')

第五层:id。

用元素的 HTML id 属性定位。id 在页面内唯一,但前端经常改。

page.locator('#username-input')

第六层:text。

用元素文本内容定位。适合按钮、链接这类有可见文字的控件,但对多语言场景不稳定。

page.get_by_text('登录')

第七层:CSS 选择器。

最后兜底的手段。CSS 类名是最不稳定的,因为前端改样式的频率最高。但其他策略都失败时,它总比没有好。

page.locator('.btn-primary')

当七层都失败时:L2 DOM 推断

你可能会问:"七层都试过了还是找不到元素,那怎么办?"

七层都失败的情况确实存在——比如整个组件的 DOM 结构重构了,所有的 id、class、testid 都换了。这时候进到L2 DOM 推断

L2 的做法是:在页面范围内做模糊匹配——例如按 id/text 的contains规则、按钮文案等猜测目标元素(不是严格的「购物车区域 DOM 子树」作用域推断,那还在规划里)。

L2 的策略是:

  1. 在全页或候选作用域内缩小搜索
  2. contains类文本/属性模糊匹配
  3. 结合元素类型(button/input 等)做猜测

如果 L2 找到了元素并且操作成功,这算"自愈成功",但会标记为level=L2, warning——因为这是模糊匹配,不代表它找到的一定是你想找的元素。系统会生成一个告警,建议人工复核这个定位器。


L3:input type 扫描兜底

L2 也失败了怎么办?还有一个 L3 兜底。

L3 专门对付输入框:当你在一个 form 里找不到#username-input这个元素时,系统会扫描作用域内所有input标签,根据type属性(text、password、email)结合前后上下文做猜测。

这个设计当然不精确,但在其他策略全部失灵的极端情况下,它有可能帮你把脚本救回来。

为了方便记忆,七层定位降级链汇总如下:

层级定位策略稳定性适用场景
L1data-testid★★★★★前端埋了测试专用属性,最稳定
L2role + name★★★★有语义角色的元素,依赖无障碍规范
L3label★★★★表单输入框,有<label>关联
L4placeholder★★★带占位文本的输入框
L5id★★HTML id 属性,前端经常改
L6text★★按钮/链接等有可见文字的控件
L7CSS 选择器最后的兜底,类名最易变
L2 DOM 推断作用域模糊匹配七层全失败时的救场,需人工复核
L3 input 扫描type 属性猜测极端兜底,仅限输入框

自愈引擎是怎么知道"从哪里试起"的

你可能会想:"设计成依次尝试七种策略,每次都要试七次,性能怎么办?"

好问题。实际上在代码生成阶段,系统就已经为每个元素生成了定位策略的提示信息LocatorHints)。这个提示信息包含了一组预计算的候选定位器:

LocatorHints( testid='login-button', role='button', name='登录', label='', placeholder='', element_id='login-btn', text='登录', css='.btn-primary' )

系统不是盲目地七种策略全试一遍,而是按照testid → role → label → placeholder → id → text → css的顺序,用第一个能成功定位的策略。一旦成功了就停下来,不再尝试后面的。

而且系统对每个元素的自愈结果做了LRU 缓存(Least Recently Used,最近最少使用缓存)——同一个元素在同一个页面上,第一次自愈成功后,后面的操作直接复用这个策略,不用每次重新试。


离线扫描 + 选择器注册表

上面的自愈是"运行时"的——脚本挂的时候现场自救。

但还有另一种场景:你希望在脚本运行之前,就知道页面上的元素还找不找得到。

UI-skills 的「分析」模块里有scan_page_dom.py:打开目标页面,扫描 DOM,输出选择器注册表(import / merge / query)。当前实现支持注册表的导入、合并和查询,尚未内置「与历史基线 diff、CI 提前告警」——若要提交前对比,需要你自己扩展或人工 review 扫描结果。

这个机制的价值在于:先把页面元素定位信息结构化落盘,为后续维护和扩展对比打基础;运行时自愈仍是主路径。


举个场景

假设登录页的前端做了一次渐进式改版,传统写法可能是这样:

await page.fill('#username', 'admin') # 通过 id await page.fill('#password', 'password123') # 通过 id await page.click('text=登录') # 通过 text

假如前端做了一次重构——把 id 去掉了,换成了data-testid,按钮文字从"登录"改成了"Sign In"。

传统脚本的结局:三个操作全部失败,脚本红 3 个。

自愈引擎的流程:

  1. #username定位失败 → 尝试 testid → 成功(data-testid="username-input"
  2. #password定位失败 → 尝试 testid → 成功(data-testid="password-input"
  3. text=登录定位失败 → 尝试 testid → 没有 → 尝试 role →button, name=Sign In→ 成功

最终:3 个操作都自愈成功,脚本全绿。自愈日志里记录了三行healed=true, level=L1, strategy=testid/role


UI 自动化的维护成本,能降多少?

两件事:

  • 把定位器的维护从"改代码"变成"看日志"。以前前端改版,你要修改 Playwright 脚本里的选择器,改完还要 review、提 PR。现在前端改了,自愈引擎在运行时就搞定了,你只需要每天花 30 秒扫一下自愈日志,确认 L2 级别的告警是否需要人工介入。

当然,自愈不是银弹。如果前端把整个页面从 SPA 重构成了 SSR,DOM 结构完全不一样,L2 推断也救不了。但对于日常的渐进式改版——改个类名、改个文字、调一下层级——七层降级链通常能兜住大部分定位器失效。


一个小行动:从一条降级链开始

如果你也在被 UI 自动化的维护成本困扰,不妨从一个页面开始试试。

挑一个你最常用的页面,给它的关键操作元素列一个 LocatorHints:testid 没有就用 role,role 没有就用 text——写个简单的try/except降级,不成功就换下一个策略。跑几天,看看有多少"挂了"其实是"换个策略就能找到"。

别贪多,一个页面、一条降级链就够了。体验过那种"脚本自己救了自己"的感觉,你就回不去了。


为什么自己拼很难

「Playwright 定位器降级」这个概念,GitHub 上能找到不少示例代码。七层策略本身也不神秘。

难的是三层工程咬合

第一,codegen 阶段就要预埋 LocatorHints。不是跑的时候盲目试七种 API,而是生成脚本时每个元素就带好 testid、role、name 等候选字段。这要求 UI spec 的解析规则和generate.sh的 Playwright 模板是一套东西——你手写脚本可以加降级,但做不到「写 spec 就自带自愈」。

第二,运行时和离线是两条线。运行时靠fill_with_heal/click_with_heal+ LRU 缓存;跑之前靠 DOM 扫描产出选择器注册表。两条线共用同一套定位优先级约定。

第三,和接口链路共用产出约定。UI 脚本、接口脚本、压测脚本都从 spec 进output/,pytest 失败走同一套 conftest 钩子。UI 自愈日志里的healed=true要能被人看懂,也要能进后面的缺陷分析和知识回灌——又是跨模块的接口对齐。

你可以把七层降级、L2 作用域推断这些思路搬到自己项目里,立刻就能减少一些维护量。但要做到「spec 一键生成带自愈的 Playwright、和整条测试产线咬合」,半年量级的打磨省不掉。


一个小总结

UI 自动化维护难,不是因为 Playwright 不好用,而是因为我们一直用"硬编码"的思路去定位"软变化"的元素。页面是会变的,而定位器是静态的——这个矛盾,才是 UI 自动化维护成本居高不下的根本原因。

解决思路不是"写得更好",而是"写得更有弹性"——给每个元素准备多套定位方案,一套不行换一套,七套都试过了还不行?标记 warning,等人复核。

下一篇,我专讲性能压测——怎么从一份 spec 生成 k6/JMeter/Locust 脚本?压测报告怎么做成自动化流水线?(上线前的全站截图巡检也会在这一篇里展开,和压测组成「双保险」。)

七层降级链的设计思路欢迎直接借鉴;完整技能包里还有 NL 一句话 UI 测试、选择器注册表扫描等与 codegen 绑定的能力,基线 diff 需自行扩展或联调。