前阵子有个朋友跟我吐槽,他维护的那套 Playwright 用例在项目改版之后挂了一半,排查下来全是 XPath 写死了结构。我问他为什么不试试用户视角的定位方式,他愣了一下问:用户视角也算定位器?这刚好引出了这一篇笔记的主题——Playwright 里那些不以 DOM 结构为核心、而是模拟“用户怎么看页面”的定位方法。
在 Playwright 测试用例里,我们绝大多数时间都在用 CSS 选择器和 XPath 写定位,比如#login-btn、//div[@class='header']/button。这类定位器有一个共同的毛病:它们依赖实现细节,只要前端把标签换一下、加一层 div、把 class 从login-btn改成submit-btn,你的测试用例就跟着挂了。可对用户来说,页面上改变的可能只是一个样式,按钮的功能和文字一点没变。Playwright 很早就意识到了这个痛点,所以内置了一套面向用户视觉的定位体系,也就是这一篇要展开的 getByRole、getByLabel、getByText、getByPlaceholder 等定位器,以及在此基础上配合 DOM 关系链组合出的定位策略。这篇文章适合已经会写基础 Playwright 脚本、但想减少用例维护量的人,也适合准备把老套件从 XPath 迁移到更健壮定位风格的团队。
1. 用户能看见的,才值得写进定位器:getByRole 与可访问性树
1.1 可访问性树到底是什么
先说一个很多同学没注意过的底层机制:浏览器渲染完页面之后,除了构建 DOM 树和渲染树,还会生成一棵可访问性树(Accessibility Tree)。这棵树是从辅助技术(比如屏幕阅读器)的视角描述的页面,里面每一个节点都包含 role(角色)和 name(名称)等信息。比如一个按钮在可访问性树里就是role=button,它的名字可能来自按钮文本、aria-label 或者被关联的 label 文本。
Playwright 的getByRole定位器,本质上就是在这棵可访问性树里做查询,而不是直接翻 DOM。这样做有一个显而易见的好处:可访问性树比 DOM 稳定得多。前端可以把<button>改成<div role="button">,DOM 结构变了,但可访问性树里它依然是 button,你的定位器不用改。也可以把<a>改成<button>去实现点击,对用户来说没有区别,对 getByRole 来说也没有区别。
我第一次理解这个机制的时候就觉得,这是“用户视觉定位”最核心的思想:用户不关心这个按钮是用 div 还是 button 实现的,用户只关心这里有一个可以点、并且写着“登录”的东西。可访问性树恰好就是把这种用户感知抽象成了机器可查询的数据结构。
1.2 getByRole 的基础用法与 name 匹配
在实际用例里,我写得最多的两类 getByRole 大概是这样的:
await page.getByRole('button', { name: '登录' }).click(); await page.getByRole('heading', { name: '欢迎回来' }).isVisible();第一个参数是角色类型,可选值很多,常用的有button、link、heading、checkbox、radio、textbox、combobox、dialog、tab、listitem等。第二个参数里的name就是匹配可访问性树里的名称,默认是大小写不敏感且做模糊匹配的,也会忽略首尾空格。
这里有一个容易踩的坑:可访问性名称(accessible name)的计算规则比你想的要复杂。它会依次尝试aria-labelledby、aria-label、元素内部文本、关联 label 文本等,优先级依次降低。这意味着:
- 如果按钮没有内部文本,但有
aria-label="关闭",getByRole('button', { name: '关闭' })能命中; - 如果按钮内部有文本“关闭”,但同时设置了
aria-label="Close",那么可访问性名称会是 “Close”,你用中文“关闭”去匹配就找不到了。
碰到这种时候别急着怀疑 Playwright 坏了,先打开 DevTools 的 Accessibility 面板看一眼元素当前的 accessible name 到底是什么。我遇到过的不少“getByRole 定位失败”,最后都是栽在 aria-label 覆盖了文本这种细节上。
1.3 用 role 定位比 CSS/XPath 稳在哪里
我拿一个实际例子来对比。之前我帮一个团队迁移一套登录页用例,原来的写法是:
await page.locator('.login-form .btn-primary').click();结果某个版本前端把按钮的 class 从btn-primary改成了submit--main,全部用例当场崩。改成 getByRole 之后:
await page.getByRole('button', { name: '登 录' }).click();前端再怎么改 class、改标签结构,只要用户看到的还是一个写着“登录”的按钮,这条定位就永远有效。
这不是说 CSS 和 XPath 没用,而是说它们不适合作为首选。CSS 适合那些功能和用户语义都不明确的装饰性元素,比如一个只有图标的关闭按钮、一个无文字的空白分割线;XPath 则适合确实需要依赖层级关系才能唯一定位元素的时候。但凡是“用户能给这个元素起个名字”的情况,用 getByRole 都会稳得多。
还有一点要提:getByRole 本身就是严格模式的一部分。当页面上有多个符合条件的元素时,Playwright 会直接报 strict mode violation,而不是静默选择第一个。这个特性表面上看是麻烦,实际上是在逼你尽早发现页面上存在重复语义的元素,比等到生产环境被用户点错按钮再发现要好得多。
1.4 角色状态与过滤条件
除了name,getByRole 还支持用checked、disabled、selected、expanded、pressed这些可访问性状态来做过滤。举个例子:
await page.getByRole('checkbox', { name: '我已阅读协议', checked: false }).check(); await page.getByRole('tab', { name: '订单记录', selected: true }).click();这类状态过滤在写复杂交互用例时非常有用。尤其是 tab 切换、菜单展开这类场景,DOM 里可能同时存在多个相同角色和相近文本的元素,单靠 name 区分不了,但加一个状态条件就能唯一锁定。这也是“用户视觉”的一个维度——用户看一个 tab 时,不仅看到文字,还会感知它是不是当前激活的那个。
2. 画面上有文字的地方都能找:getByText、getByLabel 与其它文本类定位器
2.1 getByText 的匹配逻辑与 exact 选项
getByText 是另一个高频使用的视觉定位器,它直接按用户可见的文本内容匹配元素。这里的“可见”是重点:Playwright 默认会匹配文本节点的内容,包括子元素里的文本,但不会去匹配隐藏元素的文本(display:none、visibility:hidden 这类)。
默认情况下它做的是子串匹配,也就是只要元素的文本包含你给的字符串就算命中。所以如果页面上同时有“加入购物车”和“加入购物车(2 件)”,getByText('加入购物车')很可能命中两个元素,触发 strict mode violation。要精确匹配,需要加exact: true:
await page.getByText('加入购物车', { exact: true }).click();另一个常见干扰项是文本里的换行和空格。比如按钮文本在源码里写成:
<button> 登录 </button>浏览器渲染后用户看到的是“登录”,但文本节点里其实带着首尾换行和空格。getByText('登录')因为默认做模糊匹配,一般能命中;但如果你用exact: true,就大概率会失败。这时候可以用正则表达式去匹配空白:
await page.getByText(/^\s*登录\s*$/).click();我个人的习惯是:能用 getByRole 的按钮、链接、标题、表单控件,就不要用 getByText。getByText 最合适的场景是定位那些没有明确角色语义的自定义文本块,比如一个商品描述、一段提示信息、一个状态栏文案。角色定位优先,文本定位兜底,这个顺序能省掉很多定位器冲突。
2.2 getByLabel:表单可访问性定位的第一选择
在表单页面,getByLabel 是我推荐的“视觉定位主力”。它的匹配对象是所有与表单控件关联的 label 标签,或者控件自身的aria-label、aria-labelledby。用法非常直观:
await page.getByLabel('用户名').fill('zhangsan'); await page.getByLabel('手机号').fill('13800138000');你可能想问,为什么不直接用getByPlaceholder('请输入用户名')?因为 placeholder 只是输入框里的灰色提示文字,一旦用户开始输入,它就消失了。而且很多前端框架对 placeholder 的管理比较随意,同一个输入框在不同页面版本里可能换个提示文案。label 则不同,它是和控件语义绑定在一起的,即使你输入了内容、即使前端改版,label 和输入框的关联一般不会被破坏。
Playwright 官方甚至有一个明确的排序建议:getByRole 优先,getByLabel 次之,getByPlaceholder 再次之。原因很简单:placeholder 是“提示”,label 是“名称”,名称比提示承载了更稳定的语义。
这里有个小坑需要注意:如果页面里既存在 label 又设置了 aria-label,可访问性名称会优先取aria-label。也就是说getByLabel('用户名')可能匹配到 name 为“用户名”的控件,但不一定匹配的是<label>用户名</label>这个节点。你不用太纠结它匹配的是哪个来源,只需要知道匹配的依据是“控件在可访问性树里的名称”,而不是某个具体的标签节点。
2.3 getByPlaceholder、getByAltText、getByTitle 的使用边界
这三个定位器都属于“有明确的用户视觉线索时才建议使用”的类别。
getByPlaceholder:适合那些没有 label 的真实场景,比如一个搜索框只有 placeholder 没有 label。但这属于 UI 本身的可访问性缺陷,写自动化的时候可以先用它兜底,同时值得在测试反馈里建议开发补上 label。getByAltText:专门匹配图片的 alt 文本。对用户来说,图片加载失败时显示的就是 alt 文本,屏幕阅读器读的也是它,所以这确实是用户视觉的一部分。适合定位图片链接、图片按钮。getByTitle:匹配元素的 title 属性或<svg>里面的<title>标签。这个优先级很低,因为 title 在鼠标悬停时才出现,不可见且容易被忽略,不是很好的视觉线索。
我用一个表格总结这组定位器的取舍,方便你直接在项目里做选择:
| 定位器 | 匹配依据 | 典型场景 | 稳定性 | 推荐度 |
|---|---|---|---|---|
| getByRole | 角色 + 可访问性名称 | 按钮、链接、标题、表单项 | 高 | 首选 |
| getByLabel | label / aria-label | 表单输入框、下拉框、复选框 | 高 | 首选 |
| getByText | 可见文本内容 | 提示文案、无语义文本块 | 中高 | 常用 |
| getByPlaceholder | placeholder 提示 | 无 label 的输入框 | 中低 | 兜底 |
| getByAltText | 图片 alt 文本 | 图片链接、图片按钮 | 中 | 一般 |
| getByTitle | title 属性 | 悬停提示、SVG 图标 | 低 | 尽量不用 |
如果你发现一个输入框既有 label 又有 placeholder,那就直接用 getByLabel,别给自己留后续踩坑的机会。
3. 沿着用户视线画路径:DOM 关系链与组合定位
3.1 filter + hasText:先圈范围,再精确定位
视觉定位并不总是直接一步到位。用户在实际页面上找东西时,往往会先看到一个区块,再在这个区块里找某个按钮。比如商品卡片:用户先识别出“这是 iPhone 15 的卡片”,然后才去找卡片上的“加入购物车”。Playwright 支持把这种认知路径直接翻译成定位策略:
const card = page.locator('.product-card').filter({ hasText: 'iPhone 15' }); await card.getByRole('button', { name: '加入购物车' }).click();这里第一步用 CSS 选出了所有商品卡片,再用filter({ hasText: 'iPhone 15' })把范围缩小到包含目标文本的那一张,最后在卡片范围内用 getByRole 找按钮。整个过程很像用户的行为:先看卡片标题,再决定点哪个按钮。
filter({ has: locator })是另一个过滤维度,它要求元素内部必须包含某个特定的子元素。比如:
const row = page.locator('tr').filter({ has: page.getByRole('checkbox', { name: '选择订单' }) });这在处理表格时特别有用:你想定位某一行的操作按钮,但那一行没有唯一文本,倒是行内有一个唯一状态的复选框,那就可以靠这个复选框反查整行。
3.2 父子级链式定位与 and/or 组合逻辑
除了 filter,Playwright 的 locator 还支持在定位结果上继续调用 locator,形成父子级链:
await page.locator('.nav-menu') .getByRole('link', { name: '个人中心' }) .click();这条链的含义是:先找到.nav-menu,再在它内部找 role 为 link、名称含“个人中心”的元素。相比写一条长长的 XPath,这种分段写法有两个优势:一是每一段都有明确的语义,排错时一眼能看出来是哪一段出了问题;二是每一段都可以单独调试,在断点里执行.count()就能判断范围是否圈对。
and和or则用来合并条件。or的场景比较有意思,比如一个按钮在某种权限下显示为“添加”,另一种权限下显示为“新建”,你想不管哪种情况都点同一个位置:
await page.getByRole('button', { name: /^(添加|新建)$/ }).click();用正则当然可以,但更直观的写法是:
await page.getByRole('button', { name: '添加' }) .or(page.getByRole('button', { name: '新建' })) .click();and用得相对少,但在多个条件叠加时很管用,比如:
await page.getByRole('button', { name: '提交' }).and(page.locator('.primary')).click();3.3 用户的眼睛能穿越 shadow DOM 和 iframe 吗
这部分是很多初学者最容易卡住的地方。用户看页面时,不会觉得弹窗内容、子卡片内容是“另一个世界”,但浏览器在技术实现上把它们隔开了。
Shadow DOM:Playwright 对 open shadow DOM 的处理是自动穿刺的。也就是说,正常情况下你不需要做任何特殊处理,page.getByText()就能直接命中 shadow 内部的文本。这一点比很多老牌 UI 自动化框架省心得多。但需要注意,如果你用了locator的某些特定模式,比如page.locator('app-card')去选自定义元素,它返回的是宿主元素而不是内部节点,要再往下钻一层才能拿到 shadow 里的内容。
iframe:这个 Playwright 不会自动穿透,必须先用 frameLocator 声明进入哪个 iframe,再在里面定位:
const frame = page.frameLocator('#pay-iframe'); await frame.getByRole('button', { name: '确认支付' }).click();部分真实项目里会遇到嵌套 iframe,写法和单层一样,无非是 frameLocator 之后再调 frameLocator。你可以把 frameLocator 理解为“先在视觉上锁定一个窗口区域,再在这个窗口里做用户视觉定位”。这也是符合用户认知的:用户看到一个支付弹窗,会先在视觉上聚焦这个弹窗,然后在里面找按钮。
3.4 关系链定位在实际项目中的排错思路
关系链定位最麻烦的地方在于:链子越长,出问题时越难判断断在哪一环。我的排查习惯是拆开验证。假设这条链挂了:
await page.locator('.order-list').filter({ hasText: '订单号 A1001' }).getByRole('button', { name: '取消订单' }).click();我会逐步执行:
await page.locator('.order-list').count(); // 列表在不在? await page.locator('.order-list').filter({ hasText: 'A1001' }).count(); // 目标行筛没筛到? await page.locator('.order-list').filter({ hasText: 'A1001' }).getByRole('button').allTextContents(); // 这一步里面到底有哪些按钮?最后一步几乎总能暴露问题:可能是按钮名不是“取消订单”,而是“取消”加一个图标;也可能是该行有多个按钮,触发了 strict mode。用allTextContents()把所有候选按钮的文本打出来,一眼就能定位原因。这种排查方式比直接看 Playwright 的报错信息效率高很多,推荐你也养成这个习惯。
4. 当“视觉定位”升级成图像与 AI:截图对比和模型驱动的边界
4.1 坐标定位:最后的“视觉”手段
讲完基于语义和文本的定位器,还得提一嘴坐标。很多教程会把坐标定位跟视觉定位混在一起,但我对它的态度很谨慎。
locator.boundingBox()可以拿到元素在当前视口中的几何位置,配合page.mouse.click()可以做点击,但这只能作为兜底方案,因为坐标对分辨率、滚动位置、窗口大小极其敏感。我做 UI 自动化时,只有在一种情况下才用坐标:目标元素确实没有任何文本、角色、属性可供定位,而且用图像匹配都不可靠的时候。比如一个自定义绘制的 canvas 组件,里面画了几个可以点击的区域,但 HTML 层面只有一个 canvas 节点,这时候不得不先拿截图找区域中心点,再换算坐标点击。
4.2 像素级截图对比:视觉回归的常识与坑
如果项目强依赖视觉效果本身——比如页面配色、间距、字体渲染这些,文本和角色定位帮不上忙,你需要的是视觉回归测试。常规做法是:基线版本跑一遍,截取指定元素或整页的截图保存下来;改动后再次截图,用像素对比算法找出差异。
Playwright 本身自带toHaveScreenshot()断言,可以每帧对比截图,但它更偏“元素快照对比”,适合验证 UI 没有意外变化。如果要做更灵活的像素差异分析,可以把page.screenshot()的图片交给 pixelmatch 这类库去算 diff,然后输出差异热力图。
这里要提醒几件事:
- 截图对运行环境极其敏感。同一套代码在 Linux 和 Mac 上渲染可能差几个像素,字体缺失更是灾难现场,所以视觉回归最好只在固定的 Docker 环境里跑,并且锁定字体版本。
- CSS 动画会污染截图对比,在截图前要么等待动画结束,要么加
page.emulateMedia({ reducedMotion: 'reduce' })。 - 整页截图对比的成本比元素级截图高很多,建议尽量用
locator.screenshot()只截关键区域。
4.3 AI 视觉定位和 MCP:midscene 被 Playwright 调用的原理
最近圈子里讨论得比较多的是 AI 视觉定位,以及 Playwright 生态里的 MCP(Model Context Protocol)方案。很多人问过我这个问题的版本是:midscene 被 Playwright 调用是什么原理,以及 Browser Use MCP 和 Playwright MCP 有什么区别。我试着用大白话讲一下我的理解。
这类方案的核心思路是:不再把定位器写死在代码里,而是把“找到某个元素的意图”交给多模态模型理解。以 midscene 为例,它可以在 Playwright 脚本里以类似ai('点击登录按钮')的 API 出现。底层做的事情大致是:先由 Playwright 把当前页面截图,甚至收集一份可访问性快照,然后把截图和自然语言指令一起发给模型,模型返回一个包含目标元素坐标或定位线索的结果,再由 Playwright 执行真正的点击或输入。
换句话说,定位器的生成从“人写”变成了“模型推理”。这解决了两类问题的痛点:
- canvas、WebGL、复杂图表这类 DOM 语义几乎为零的页面。传统定位器都没有抓手,但模型可以看截图直接判断“图上哪个位置是柱状图的最高柱”。
- 高频改版的页面。改版后视觉布局变了,但用户意图没变,AI 方案不需要重写定位器,重新推理一次就行。
Playwright MCP 则不完全是同一个东西。它主要是给 AI 编程助手用的:通过 MCP 协议把 Playwright 的能力暴露给大模型,让模型直接去驱动浏览器、读页面、改代码。你可以理解为“AI 拿着 Playwright 当手,去操作页面并自动写测试”。Browser Use MCP 也类似,都是 MCP 生态里的浏览器操作接口,差异主要在实现细节、支持的浏览器指令集、以及和各自 Agent 框架的耦合深度上。
4.4 什么时候该用 AI 视觉定位,什么时候该忍住
尽管 AI 视觉定位越来越火,我的建议还是先冷静评估。它最大的问题是不确定性:同样是“点击登录按钮”,模型受页面布局、按钮配色、截图清晰度影响,返回的坐标可能会有波动。你可以在用例里加断言验证点击后的结果状态,但“点击坐标可能偏了那么几个像素”这种问题,在真实环境里很难复现和定位。
适合用 AI 视觉方案的场景我总结为三类:
- 传统语义定位完全失效的:纯 canvas 图表、WebGL 可视化、视频播放器控件。
- 页面结构极度不稳定:几乎每周改版,且开发没有维护 testid 的习惯。
- 一次性或低频任务:比如做一个脚本,临时要去某个页面拿数据,不值得花一两个小时精确定位。
不适合用 AI 视觉方案的场景则是:一切可以用 getByRole、getByLabel 等稳定定位器解决的问题。因为稳定定位器是确定性的,跑一百次是一百次相同的选择逻辑;AI 推理则是概率性的,今天能过不代表明天也过。
另外,不管用什么视觉方案,做一个合法的自动化脚本前,都得先确认目标站点的条款允许自动化访问,注意请求频率,别把自己的 IP 搞进黑名单,也别给对方服务器造成压力。这些不是场面话,是做这行最基本的边界感。
5. 从项目角度看定位策略选型与常见报错排查
5.1 一套我实测有效的定位器优先序
在真实项目里,我没见过哪个团队只用一种定位器打天下。最后能长期低维护成本的,基本都是按固定优先级来选择定位器的方案。我在团队里推的优先序是这样的:
- getByRole + name:能描述用户语义的元素,一律用它。
- getByLabel:所有表单控件,只要 label 可用。
- getByTestId:业务语义不明确、或者上述定位器会命中多个元素时,在代码里约定
>