录脚本的时候一切正常:点登录按钮、选下拉、断言弹窗出现,全部通过。第二天跑回归,红的不是一条两条 —— 而且失败信息都长得很像「找不到元素」。
打开页面看一眼,元素明明还在,只是:
<div class="btn-primary"> 换成了 <button>;<div>,nth-child(3) 从此指向别的行;定位器能活多久,基本在你写下它的那一刻就定了。这篇文章聊我们在这方面做的几个选择,以及踩到的三个坑 —— 这三个坑的共同点是:录的时候完全正常,回放的时候必然失败。

这是全项目的统一口径,手动拾取、生成、失败自愈三处用的是同一份候选逻辑(口径不一致是更难查的问题)。
排前面的理由很朴素:
data-testid)是给测试用的契约,不随样式和文案变;有一条是刻意不做的:不为了凑出一个定位器而硬塞 nth-of-type。如果这个元素确实只有结构性路径可走,我们宁可这条候选不生成 —— 一个三个月后必然红掉的定位器,比没有定位器更浪费时间。

组件库的类名里混着大量随交互实时变化的状态类:open / focused / active / expanded / checked / selected / loading、status-success|error|warning|validating、Element Plus 的 is-focus / is-active / ...。
你拾取弹窗里的元素时,弹窗是展开状态,容器带着 open;回放时页面是初始态,弹窗还没展开 —— 定位器要么命不中,要么命中另一个「碰巧同类」的元素。
处理很简单:生成 CSS 之前把这批类剔掉。
// 编入 CSS 会导致回放(页面初始态)必不命中或命中错误元素
const TRANSIENT_CLASS_RE =
/(^|-)(focused|open|active|expanded|checked|selected|loading)$|(^|-)status-(success|error|warning|validating)$|^is-(focus|focused|active|open|expanded|checked|selected|error)/;
const stableClasses = (el) => [...el.classList].filter((c) => !TRANSIENT_CLASS_RE.test(c));
这个正则现在有两份:页内候选生成一份、服务端验证一份(因为验证要在 Node 侧再算一遍唯一性)。两份必须同步,改一个忘另一个,就会变成「生成时剔了、验证时没剔」的错位。
textContent 里的无障碍播报文本让文本值翻倍Ant Design 的 Select 交互后会往 DOM 里插一个播报节点:
<span aria-live="polite" style="width:0;height:0;position:absolute;overflow:hidden;opacity:0">已选中的项</span>
textContent 会把这段播报文本和可见的选中项拼起来,「智能体平台」于是变成「智能体平台智能体平台」。
更要命的是它是交互期瞬态出现的:录的时候存在,回放的时候(静态 DOM)不存在,于是按它生成的 text 候选 100% 命中失败。
现在的做法是文本候选取可见文本(遍历时跳过 display:none / visibility:hidden / opacity:0 / sr-only 零尺寸节点),并且超过 80 字符就不做 text 候选。至于唯一性计数,仍然按 textContent 走 —— 这样和 Playwright getByText 会匹配隐藏文本的语义一致,被污染的候选会因为「匹配数 > 1」被自然拒掉。
getByRole 只接受标准 ARIA role。元素上如果写着 role="xxx-custom",生成的 role 候选在回放时不是找不到元素,而是直接抛 Unknown role —— 整步都执行不了,错误信息还容易把人带到别的方向。
所以显式 role 不在标准集合里时就不生成 role 候选,回落到其他策略。
同一类问题还有一个变体:antd / element-plus 的 checkbox、radio 外面包了一层 <label>,能点中的其实是包裹层,但包裹层没有 role 映射。只按包裹层生成就会落到 css,并且把点击后的交互态类编进去。现在的做法是从内部那个唯一的 input 取 role 和可访问名,补一个 role 候选。

弹窗、下拉、抽屉一般通过 portal 渲染到 body 下,位置会漂移 —— 靠 nth 序号定位弹层里的元素基本等于抽奖。
所以元素在弹层容器内时,候选会自动带上作用域:先唯一命中容器,再在容器内定位,CSS 也相对容器生成,并且不再生成绝对 xpath。
容器描述的顺序是:优先 role="dialog",其次是容器自身唯一的 class css 或 #id;如果找不到唯一描述,就不生成作用域(一个不唯一的父范围,会让「在容器内定位」变成另一种不确定)。
作用域也能手工干预:步骤表里可以添加/编辑/折叠/删除作用域,role 策略能同时编辑 role 和无障碍名,还有一个「瞄准」图标可以只拾取容器、保留原来的目标定位器。
一个容易踩的实践点:portal 下拉挂到 body 时,不能用视觉上的弹窗当范围 —— 父范围必须是目标真实的 DOM 祖先,同时必须唯一。另外作用域不支持嵌套,也不用于接口断言和页面滚动。
拾取不是「点一下就扣下元素」。实际交互是这样(页面顶部状态栏的原话):
拾取元素:普通点击可正常展开页面;按住 Alt 点击要拾取的元素,再点「确认拾取」完成 · Esc 取消
Esc 先撤销待确认,再按一次取消整个拾取;这里有一条值得单独说:预览栏显示的就是最终会落库的定位器。它不是页内估算,而是把候选交给后端用真实 Playwright 验一遍(匹配数 === 1,且命中的就是你要的那个节点),再把结论回写到预览栏。你在按「确认拾取」之前看到的,已经是验证后的结果。
验证里还有一个为 SPA 准备的降级:目标元素如果在你点击之后被重渲染替换了(引用脱离文档),只要 URL 没变,就按唯一性采信;跨导航不降级 —— 那种「唯一」很可能命中的是另一个页面上的同结构元素。
对 Ant Design / Element / Vant 这类组件库,还有一类组件感知候选:由插件贡献,按「组件库 × 能力域」拆成细粒度插件(下拉、树选择、级联、日期、时间、滑块……),内置预设带了 48 个成员插件。
它们插在通用候选前面(通常质量更高),但仍然要走同一套验证:唯一 + 命中同一节点。谁都不能绕过规则。


我们把「定位策略排序 + 排除瞬态因素 + 弹层锚定作用域」当作标准动作,但定位器这件事各家差异很大。见过做得更好的:后端在 CI 里跑一次定位器体检(对每个定位器验证唯一性,随代码提交回归)、开发在提测前跑一遍 testid 覆盖率检查、以及把定位器和页面结构一起纳入 Code Review。
你们团队现在怎么管定位器?有没有「当时觉得稳、三个月后集体翻车」的经历? 欢迎在评论区聊聊。
本项目已收录进 TesterHome 社区开源项目库:TestDog。
快速开始:https://softwing.top/testdog-doc/guide/getting-started.html
利益相关:我是 TestDog 的作者,本文写的是自己的实现与踩坑,不构成第三方评测。