写自动化测试写到第六七个年头我最怕的就是两种需求一是前端临时改版二是页面上堆满毫无语义的 class 名。前者意味着整套老用例批量变红后者意味着每次定位元素都要先打开 DevTools 猜半天。直到用上 Playwright 之后“用户视觉定位”这个概念才算真正解决了我这两大痛点——不需要再去抠 DOM 树里某个节点长什么样而是直接问自己用户在这页面上用眼睛看到什么、用什么语义去理解它。这套思路下来脚本好写了维护也好多了。作为 Playwright 学习笔记的第 07 篇这里默认你已经看过前几篇里 getByRole 的详细用法。这一篇把剩余的常用用户视觉定位方法一次讲透getByText、getByLabel、getByPlaceholder、getByAltText、getByTitle再加上 getByTestId 这个“面向测试的视觉锚点”。最后我会用自己踩过的坑聊聊 Codegen 自动生成以后怎么微调、以及大家问得最多的严格模式报错怎么处理。1. 为什么 Playwright 要把定位方式改成“用户视角”1.1 传统 id / XPath 定位到底哪里不好用手动写自动化的人都有过这种经历想定位登录按钮打开 DevTools 一看button 的 class 是 btn_base__3fTs2 btn_primary___2rY9vid 根本没写只能从某个父级 div 一路写 XPath 找下去。这种定位在写出来的那一刻还能用可前端只要改一个 class 名测试直接挂掉。更难受的是React、Vue 这类组件库在渲染时经常生成动态 id刷新一次变一次拿它当稳定锚点根本是赌运气。XPath 和 CSS 本身没有问题问题在于它们把“DOM 树结构”当成唯一的坐标系而 DOM 结构恰恰是前端最容易改动的部分。一个页面在不同状态下父子层级、兄弟节点顺序都可能变化但用户看到的按钮文字、表单标签、图片替代文案这些“视觉信息”通常不会因为一次样式重构就消失。所以 Playwright 的设计哲学很直接定位器应该尽量描述“用户能看到的东西”而不是“开发者在调试面板里看到的东西”。这种面向用户可见文本、可感知语义的定位方式就是“用户视觉定位”。可读性这层原因也很重要。满屏的 //*[idapp]/div[2]/div[1]/button 没有人愿意读第二遍而 getByRole(button, { name: 登录 }) 一写出来整个团队都知道你要点什么。自动化用例的可读性决定了它到底是一段“能跑的脚本”还是一份“能维护的资产”。1.2 “用户视觉定位”的底层逻辑用户视觉定位并不复杂本质就是模仿人的观察方式进入页面后人会通过文字内容判断这是什么按钮通过 label 判断这个输入框该填什么通过 placeholder 判断这里需要什么格式通过图片替代文字理解图片含义。Playwright 提供的 getByText、getByLabel、getByPlaceholder、getByAltText、getByTitle 一一对应这些观察维度再加上 getByRole 表达 ARIA 语义角色就构成了一套接近人类认知模型的选择器体系。这里要特别提一个设计细节这些方法返回的都是 Locator而不是直接返回某个元素也就是说它们自带自动等待能力。定位器不要求在调用那一刻元素已经存在Playwright 会在点击、填写等动作时自动等待元素出现、可见并处于稳定状态。这个行为和传统的 findElement 有本质区别自动化脚本因此少了很多手工 sleep。理解这一点后面调试各种“定位不到”的问题会顺畅很多。关于 getByRole上一篇文章已经做了完整拆解本篇不再重复展开只在组合策略里顺带使用。如果你还没看过那篇我建议先把 role 和 name 这两个关键参数搞清楚再往下读不然组合用法会有一点门槛。2. 五种常用用户视觉定位方法从文本到表单2.1 getByText按用户看到的文字来找元素在所有用户视觉定位方法里getByText 的使用频率应该是最高的。它的语义是“在页面里找一个包含指定文本的元素”支持字符串也支持正则。比如登录页的提交按钮你不用管它下面是 button 还是 div直接拿用户能看到的文案来定位// 找一个包含“登录”字样的元素 await page.getByText(登录).click(); // 精确匹配避免匹配到“登录后自动跳转”这种干扰文案 await page.getByText(登录, { exact: true }).click(); // 用正则匹配商品价格 const price page.getByText(/¥\s*\d\.\d{2}/);这里有几个容易踩的细节。getByText 默认匹配的是元素的文本内容包括子节点里的文本所以页面里如果同时存在“登录”和“登录后自动跳转”不加 exact 就可能同时命中触发严格模式报错。默认的字符串匹配是忽略大小写的子串匹配exact: true 则变成大小写敏感的全串匹配。写断言和点击动作时要清楚自己到底想匹配哪一种。第三个细节是匹配范围。getByText 不挑标签div、span、li、button 都可以所以用它定位菜单项非常自然page.getByText(个人中心).click()。但如果页面上出现多处相同文案比如商品列表里每张卡片都有“加入购物车”就需要配合过滤条件或者链式定位这个问题我在第 5 章会专门讲。我的使用建议是优先拿它处理“用户一定会读到的文字”比如按钮文案、菜单名、栏目标题不要用它匹配藏在 JS 变量里的状态文本因为那部分用户看不到定位器的稳定性也差。凡是肉眼能在页面截图里看到的文字都可以第一时间考虑 getByText。2.2 getByLabel表单控件最亲密的搭档表单用例大概是自动化里占比最高的场景而 getByLabel 正好是为表单准备的。它的工作原理是优先查找与控件关联的 label 元素。这种关联有两种方式一种是 label 的 for 属性指向 input 的 id另一种是 label 直接包裹 input另外它也会读取 aria-label 和 aria-labelledby 做兜底。对用户来说“用户名”这个标签就是输入框的意义所以拿它定位最好理解。页面结构可能是这样的label forusername用户名/label input idusername typetext /也可能是直接包裹label 用户名 input typetext / /label无论哪种Playwright 都能识别await page.getByLabel(用户名).fill(tester);这个 API 的好处在于你不用关心 input 的 id 是不是随机生成不用关心它被包了几层表单容器。只要人的眼睛能看到“用户名”三个字测试代码里就能用同样的话去定位。我自己写登录注册这类用例时几乎全部用 getByLabel 填充字段配合 getByRole 点击按钮整段代码读下来就像一份人工操作手册。要注意的是getByLabel 匹配的是可访问标签而不是视觉上的占位文案。很多前端喜欢用 placeholder 模拟标签效果但 placeholder 在无障碍语义里并不是 label所以 getByLabel 定位不到。遇到这种页面别硬扛直接切换 getByPlaceholder 更合适。另外如果同一个页面有两个叫“手机号”的 label比如登录手机号和绑定手机号分属不同表单建议先用断言确认唯一性再用 filter 或链式定位把范围缩小。2.3 getByPlaceholder输入框里的提示文案也能定位placeholder 是 input 和 textarea 上给用户看的示例文字比如“请输入手机号”“邮箱地址”。因为它直接显示在输入框里所以也是典型的用户视觉信息。Playwright 提供 getByPlaceholder 专门处理这种情况await page.getByPlaceholder(请输入手机号).fill(13800138000); await page.getByPlaceholder(搜索你想要的商品).press(Enter);这个定位方法在两种场景下格外好用一种是项目里已经用 placeholder 代替 label 的页面另一种是搜索框、筛选框这类需要“空状态提示”的组件。因为 placeholder 通常写在组件配置里开发者一般不会频繁改动所以稳定性反而不错。缺点也很明显placeholder 只在输入框为空且未聚焦时显示用户一旦输入内容就看不到了。这导致它不能给无障碍设备提供稳定的名称也不太适合作为唯一依赖点。我见过不少页面把“请输入手机号”和“请输入邮箱”的空态提示写得一模一样这种时候 getByPlaceholder 会同时命中多个输入框只能加过滤条件或者 .first() 处理。我的经验是能用 label 就用 label页面没有 label 时再用 placeholder 救急同时把这种技术债反馈给前端让后续版本补上可访问标签。2.4 getByAltText图片和图标按钮的“备用描述”alt 属性本来是给图片无法显示时看的替代文本但从“用户视觉定位”的角度理解它也承载了图片的视觉意义。比如页面上有个下载按钮是一张图片图里画着“下载”两个字那么 alt下载 就是用户能理解的信息。Playwright 因此提供了 getByAltTextawait page.getByAltText(产品主图).click(); // 也可以精确匹配 await page.getByAltText(产品主图-促销版, { exact: true }).click();它的匹配规则和 getByText 类似支持子串、正则、精确匹配主要适用于 img、area 等带 alt 属性的元素部分 SVG 也能用。实际项目里商品图、banner 图、图标按钮都很适合用它。这里有个现实问题很多公司的前端压根没写 alt或者写的是“图片1”“图片2”这种毫无意义的文本。我接过一个项目全站商品卡片的图片 alt 全是空字符串getByAltText 直接报废。这种情况要么推动前端补齐 alt要么对纯图片区域改用>await page.getByTitle(删除).click(); await page.getByTitle(切换到列表视图, { exact: true }).click();它对带 title 属性的普通元素生效也能匹配 SVG 内部的 title 子标签处理自定义图标时很好用。不过我得提醒一句title 的唯一性通常不如文本和 label 强同一个页面上可能有好几个“删除” title而且 title 在键盘操作和触屏设备上几乎不存在对部分用户来说是完全不可见的。所以它更像是“最后一个普通人能看到的信息来源”。如果你面对的是 icon font 或 SVG 图标按钮与其依赖 title我更建议让前端在按钮上加 aria-label这样 getByRole 和 getByLabel 都能识别可访问性也更好。这也是整个用户视觉定位方法论背后的共同价值它逼着团队把可访问语义做对自动化收益只是顺带的。3. getByTestId给自动化测试留一扇“后门”3.1 为什么>button>await page.getByTestId(submit-order).click();这个属性和 DOM 结构无关和用户文案无关改任何样式都不影响所以前端同学可以放心地把它当成“只给测试暴露的钩子”。它的价值在于把“找元素”变成“找标识”定位器写起来极其稳定。缺点也很直白测试代码会耦合前端的某一个具体属性如果>// playwright.config.js import { defineConfig } from playwright/test; export default defineConfig({ use: { testIdAttribute: data-qa, }, });如果只是在某一个脚本里临时指定也可以直接调用await page.setTestIdAttribute(data-qa); await page.getByTestId(submit-order).click();这里有一个注意点getByTestId 并不仅匹配>const cheapCard page .getByRole(listitem) .filter({ hasText: ¥89 }); await cheapCard.getByRole(link, { name: 查看详情 }).click();这种链式写法的可读性非常好先框出“包含某个文案的区域”再在这个区域内用视觉定位找目标。类似的还有 and 和 or比如把“主按钮”和“弹窗里的按钮”合并匹配const okButton page .getByRole(button, { name: 确定 }) .and(page.locator(.modal-footer));我的建议是组合越贴近业务语义越好比如“收件人卡片里的编辑按钮”这种描述读起来就像需求文档。如果组合链太长要么是页面结构本身太复杂要么是主定位器粒度选得不对这时先回到第 2 章的方法清单里重新挑一个主定位器别硬堆链式调用。4. 实战用 Codegen 录制后手动微调视觉定位4.1 用 Codegen 自动生成“所见即所得”的定位代码Playwright 自带的 Codegen 是学习用户视觉定位最直观的途径。在项目目录下执行npx playwright codegen https://example.com/login它会弹出一个浏览器窗口同时在旁边生成一份实时更新的脚本面板。因为你在浏览器里的每次点击、输入、滚动操作Codegen 都会尝试用推荐定位器生成对应代码而且默认优先使用 getByRole、getByLabel 这类用户视觉定位很少生成一长串 XPath。拿登录场景举例你手工操作一遍“输入用户名、输入密码、点击登录”生成的代码大概长这样await page.getByLabel(用户名).fill(admin); await page.getByLabel(密码).fill(123456); await page.getByRole(button, { name: 登录 }).click();这段代码基本是“可直接提交仓库”的质量。所以我非常推荐新手在学视觉定位时先用 Codegen 操作一遍看它怎么选关键词再在真实项目里模仿这种写法。它实际上是把“怎么用视觉信息描述元素”这件事变成了一个可看可学的示范过程。4.2 录制代码的常见问题与手工修正Codegen 不是万能的我在项目里常见三类“录制后遗症”需要手工处理。第一类是定位器被过度组合。比如弹窗里的输入框Codegen 可能生成 page.locator(.modal).getByPlaceholder(手机号)。如果整个页面只有一个手机号输入框这个模态框前缀就是多余噪音可以直接剪掉只有当页面有多处相同 placeholder 时这种范围限定才有保留价值。第二类是选择器退化成纯 CSS。部分自定义组件Codegen 找不到合适的语义角色会退而使用 CSS 类名比如 .ant-input。这类代码一遇到样式重构就崩我会让前端在这个组件上补一个 aria-label或者干脆加一个>await expect(page.getByText(订单提交成功)).toBeVisible(); await expect(page.getByRole(heading, { name: 我的订单 })).toBeVisible(); await expect(page.locator(#order-count)).toContainText(共 3 件);expect 的轮询机制会自动重试直到超时才失败完全不用 sleep。动作之前通常也不用手动等待点击、填写内部已经有等待逻辑。只有在处理滚动加载这类场景时我会主动用 scrollIntoViewIfNeeded 或键盘滚动触发加载再配合 expect 等待目标出现。这里顺便提一句Playwright 的监听能力也值得关注比如 page.on(dialog) 处理原生弹窗、page.on(console) 排查页面报错。它们不负责定位元素但能让用例对页面状态更可控组合起来用相当顺手这部分等后续笔记专门展开先把视觉定位这条主线走完。5. 常见问题与排查技巧实录5.1 严格模式报错同时命中多个元素怎么办Playwright 默认打开“严格模式”只要定位器解析时匹配到多个元素操作立刻报 Strict mode violation而不是闷头点第一个。这个设计初看有点烦其实是帮我们提前暴露了定位模糊的问题。报错长这样strict mode violation: getByText(登录) resolved to 3 elements解决办法按优先级来第一把选择条件写精确字符串加 exact: true或者改用正则限定前后边界第二用链式定位缩小范围比如先定位父级容器再在里面找第三确认真的是“多个同类元素里的任意一个”时再用 .first()、.last() 或 .nth(2) 做兜底。我见过不少同学一遇到这个报错就无脑 .first()这其实是掩盖问题很可能点错了元素。正确姿势是先去页面截图里确认哪个才是目标再调整定位器。调试时可以用 locator.count() 打印匹配数量快速判断是选择器太宽还是页面真的渲染了多个同类元素。这一步节省的时间远比你反复跑脚本试错要划算。5.2 文字明明在页面上却定位不到这是被问得最多的一类问题肉眼能看到“立即购买”getByText 就是找不到。我的排查顺序一般是这样的。先确认目标元素是否在 iframe 里。Playwright 的 page 层面定位器进不了 iframe需要使用 frameLocatorconst frame page.frameLocator(#dynamic-frame); await frame.getByText(立即购买).click();动态加载的 iframe 尤其容易踩外层 page 根本感知不到内部 DOM 变化。如果你发现定位器在 DevTools 里明明能查到但脚本就是找不到优先怀疑 iframe。再看是不是隐藏元素或不可见元素。定位器能匹配到隐藏元素但 getByText 的匹配范围可能包含 display:none 的节点而用户看不到所以要用过滤条件把可见节点挑出来或者先滚动到元素附近再操作。然后是文本格式问题。页面里有首尾空格、不间断空格、中文和英文之间插了零宽字符都会干扰子串匹配。修法是用正则模式比如 /立即\s*购买/或者复制页面源码里的真实文本做精确断言。最后检查是否命中了多个元素、但目标不在列表首位。先用 count() 和 allTextContents() 把所有匹配文本打出来心里有数了再精确定位。这套排查顺序基本能覆盖九成以上的“看不到”问题。5.3 元素存在、能定位到但操作仍然超时定位到了元素点击却一直等待直到超时这种情况通常不是定位器的问题而是元素状态的问题。比如按钮被灰色遮罩层挡住、有元素盖在目标上、或者按钮本身处于 disabled 状态。Playwright 的点击要求元素可见、稳定、可接收事件这三个条件任一不满足就会一直等。排查手段最先打开 Trace Viewer看操作前那一刻的页面截图是遮挡还是禁用一眼就能看出来。如果是弹层遮罩就先关闭弹层再去点击如果是 disabled就得在业务上把前置条件准备好再操作。还有一种常见原因页面存在动画元素刚出现还在移动中Playwright 自动等待会等元素停下来。这种情况多数是“再多等一会儿就好”不需要额外处理。真遇到特别慢的动画可以用 expect(locator).toBeEnabled() 或 toBeVisible() 先等状态稳定再执行点击比硬塞一个固定 sleep 可靠得多。给个我的经验脚本超时的第一反应永远不要是“把超时时间调大”而是去确认元素状态超时只是症状状态问题才是病根。5.4 什么时候真的该用>