21 KiB
选择定位策略
何时使用: 当需要决定对某个元素使用哪种 Playwright 定位方法时
快速回答
对所有具有语义 HTML 角色(按钮、链接、标题、表单字段、对话框)的元素使用 getByRole()。对于表单字段回退到 getByLabel(),对于纯文本内容使用 getByText(),而 getByTestId() 仅作为最后手段,用于没有无障碍角色的自定义组件。
决策流程图
开始:你需要定位一个元素
|
v
该元素是否有语义角色?
(button、link、heading、textbox、checkbox、combobox、dialog、img、row、cell、navigation……)
|
+-- 是 --> 使用 getByRole('role', { name: '无障碍名称' })
| |
| +-- 需要缩小范围?从父级角色定位器开始链式调用:
| getByRole('navigation').getByRole('link', { name: '...' })
|
+-- 否
|
v
它是否是一个带有可见 <label> 的表单字段?
|
+-- 是 --> 使用 getByLabel('标签文本')
|
+-- 否
|
v
它是否是静态文本内容(段落、span、带文本的 div)?
|
+-- 是 --> 使用 getByText('文本内容')
| 当文本较短或常见时,优先使用 { exact: true }
|
+-- 否
|
v
它是否有占位符(placeholder)?
|
+-- 是 --> 使用 getByPlaceholder('占位符文本')
| (不太推荐——占位符在输入时消失)
|
+-- 否
|
v
它是否有 title 属性或 alt 文本?
|
+-- 是 --> 使用 getByTitle('...') 或 getByAltText('...')
|
+-- 否
|
v
在标记中添加 data-testid="..."
使用 getByTestId('标识符')
|
+--> 绝不回退到 CSS 选择器或 XPath。
应修复标记(markup)而非使用它们。
决策矩阵
| 元素类型 | 推荐的定位器 | 回退方案 | 示例 |
|---|---|---|---|
| 按钮(Button) | getByRole('button', { name }) |
角色缺失时使用 getByText() |
getByRole('button', { name: 'Submit' }) |
| 链接(Link) | getByRole('link', { name }) |
锚点文本使用 getByText() |
getByRole('link', { name: 'Sign up' }) |
| 文本输入(Text input) | getByLabel('...') |
getByRole('textbox', { name }) |
getByLabel('Email address') |
| 复选框(Checkbox) | getByRole('checkbox', { name }) |
getByLabel() |
getByRole('checkbox', { name: 'Accept terms' }) |
| 单选按钮(Radio button) | getByRole('radio', { name }) |
getByLabel() |
getByRole('radio', { name: 'Express shipping' }) |
| 下拉框 / 选择框(Dropdown / Select) | getByRole('combobox', { name }) |
getByLabel() |
getByLabel('Country') |
| 标题(Heading) | getByRole('heading', { name, level }) |
getByText() |
getByRole('heading', { name: 'Dashboard', level: 1 }) |
| 导航链接(Nav link) | 链式调用:getByRole('navigation').getByRole('link', { name }) |
使用 locator('nav') 限定范围 |
见下方详细示例 |
| 表格单元格(Table cell) | 链式调用:getByRole('row').filter().getByRole('cell') |
限定范围的 locator('td') |
见下方详细示例 |
| 图片(Image) | getByRole('img', { name }) |
getByAltText() |
getByRole('img', { name: 'Company logo' }) |
| 模态框 / 对话框(Modal / Dialog) | getByRole('dialog') 然后在其中链式调用 |
locator('[role="dialog"]') |
getByRole('dialog').getByRole('button', { name: 'Confirm' }) |
| 动态列表项(Dynamic list item) | .filter({ hasText }) 或 .filter({ has }) |
最后手段使用 nth() |
getByRole('listitem').filter({ hasText: 'Milk' }) |
| 自定义组件(Custom component) | getByTestId('...') |
在标记中添加 data-testid |
getByTestId('color-picker') |
详细分析
第一级:getByRole() —— 默认使用
最强的定位器。它模拟了辅助技术和真实用户感知页面的方式。
优点
- 对标记重构具有弹性(类名、标签变更)
- 强制使用无障碍标记——如果定位器失效,说明你的无障碍也失效了
- 跨框架工作(React、Vue、Angular、纯 HTML)
- 支持按
name、level、checked、pressed、expanded、selected进行筛选
缺点
- 要求元素具有有效的 ARIA 角色(隐式或显式)
- 当名称重复时可能匹配多个元素——使用链式调用限定范围
何时失效:使用 <div> 堆砌且无 ARIA 角色的自定义组件。先修复组件;只有在无法修改标记时才添加 getByTestId()。
第二级:getByLabel() —— 表单字段
通过关联的 <label> 文本进行查询。这通常是表单字段最可读的定位器。
优点
- 极其可读:
getByLabel('Password')明确告诉你是什么字段 - 适用于
<label for="...">、包裹式<label>以及aria-labelledby
缺点
- 仅适用于带标签的表单元素
- 如果有人更改标签文本则会失效(但这通常是故意的)
何时优先于 getByRole('textbox'):当标签文本清晰且唯一时。当你需要按角色类型区分多个相似标签的字段时,使用 getByRole()。
第三级:getByText() —— 静态内容
通过可见文本内容查找元素。
优点
- 对非交互式文本(段落、span、徽章、状态消息)直观易用
- 支持精确匹配和子串匹配
缺点
- 如果文本是动态的、被翻译或重复的,则很脆弱
- 可能无意中匹配到父元素——使用
{ exact: true }或限定查询范围
经验法则:将 getByText() 用于断言和内容验证,而非交互式元素。交互式元素应使用 getByRole()。
第四级:getByPlaceholder() —— 无标签的输入框
通过占位符(placeholder)属性值定位。
优点
- 在缺少标签时可用(搜索栏、极简 UI)
缺点
- 占位符在用户输入时消失——用户体验基础较差
- 表明无障碍性缺失(没有标签)
将其视为黄旗警示:如果你使用了此方法,请考虑提交工单以添加合适的标签。
第五级:getByTestId() —— 最后手段
通过 data-testid 属性定位。
优点
- 完全与面向用户的文本和结构解耦
- 在 UI 重新设计时保持稳定
缺点
- 对用户和辅助技术不可见
- 污染生产环境的标记(除非在构建时剥离)
- 无法说明该元素的外观或功能
仅在以下情况使用:组件没有语义角色、没有标签、没有文本,并且你无法修改标记。常见情况:canvas 元素、第三方小部件、复杂的自定义组件。
绝不使用:原生 CSS 选择器或 XPath
// 不要这样做
page.locator('.btn-primary'); // 类名会变
page.locator('#submit-btn'); // ID 很脆弱
page.locator('div > span:nth-child(2)'); // 结构变化会破坏它
page.locator('xpath=//div[@class="foo"]'); // 不可读,很脆弱
如果你正要使用 CSS 选择器,请停下来。回到决策流程图,找一个语义定位器。如果找不到,则添加 data-testid。
实际示例
1. 按钮
// TypeScript
// 标准按钮
await page.getByRole("button", { name: "Submit" }).click()
// 纯图标按钮(使用 aria-label)
await page.getByRole("button", { name: "Close" }).click()
// 特定区域内的按钮
await page.getByRole("region", { name: "Billing" }).getByRole("button", { name: "Update" }).click()
// JavaScript
// 标准按钮
await page.getByRole("button", { name: "Submit" }).click()
// 纯图标按钮(使用 aria-label)
await page.getByRole("button", { name: "Close" }).click()
// 特定区域内的按钮
await page.getByRole("region", { name: "Billing" }).getByRole("button", { name: "Update" }).click()
2. 链接
// TypeScript
// 标准链接
await page.getByRole("link", { name: "Sign up" }).click()
// 导航内的链接
await page.getByRole("navigation").getByRole("link", { name: "Pricing" }).click()
// 精确匹配链接(避免部分匹配)
await page.getByRole("link", { name: "Log in", exact: true }).click()
// JavaScript
await page.getByRole("link", { name: "Sign up" }).click()
await page.getByRole("navigation").getByRole("link", { name: "Pricing" }).click()
await page.getByRole("link", { name: "Log in", exact: true }).click()
3. 文本输入框
// TypeScript
// 带有可见标签的输入框——推荐
await page.getByLabel("Email address").fill("user@example.com")
// 当存在多个文本框且需要按角色区分时
await page.getByRole("textbox", { name: "Email address" }).fill("user@example.com")
// 文本域(Textarea)
await page.getByLabel("Message").fill("Hello, world")
// 搜索输入框(角色为 searchbox)
await page.getByRole("searchbox", { name: "Search" }).fill("playwright")
// JavaScript
await page.getByLabel("Email address").fill("user@example.com")
await page.getByRole("textbox", { name: "Email address" }).fill("user@example.com")
await page.getByLabel("Message").fill("Hello, world")
await page.getByRole("searchbox", { name: "Search" }).fill("playwright")
4. 复选框与单选按钮
// TypeScript
// 复选框
await page.getByRole("checkbox", { name: "Accept terms" }).check()
// 验证选中状态
await expect(page.getByRole("checkbox", { name: "Accept terms" })).toBeChecked()
// 单选按钮
await page.getByRole("radio", { name: "Express shipping" }).check()
// 组内的单选按钮(使用 fieldset + legend)
await page
.getByRole("group", { name: "Shipping method" })
.getByRole("radio", { name: "Express" })
.check()
// JavaScript
await page.getByRole("checkbox", { name: "Accept terms" }).check()
await expect(page.getByRole("checkbox", { name: "Accept terms" })).toBeChecked()
await page.getByRole("radio", { name: "Express shipping" }).check()
await page
.getByRole("group", { name: "Shipping method" })
.getByRole("radio", { name: "Express" })
.check()
5. 下拉框与选择框
// TypeScript
// 原生 <select> 元素
await page.getByLabel("Country").selectOption("Canada")
// 自定义组合框(ARIA combobox 角色)
await page.getByRole("combobox", { name: "Country" }).click()
await page.getByRole("option", { name: "Canada" }).click()
// Listbox 模式
await page.getByRole("combobox", { name: "Font size" }).click()
await page.getByRole("listbox").getByRole("option", { name: "16px" }).click()
// JavaScript
await page.getByLabel("Country").selectOption("Canada")
await page.getByRole("combobox", { name: "Country" }).click()
await page.getByRole("option", { name: "Canada" }).click()
await page.getByRole("combobox", { name: "Font size" }).click()
await page.getByRole("listbox").getByRole("option", { name: "16px" }).click()
6. 标题
// TypeScript
// 指定标题级别
await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible()
// 仅按名称匹配任意标题(级别不重要时)
await expect(page.getByRole("heading", { name: "Recent activity" })).toBeVisible()
// 某区域内的标题
await page.getByRole("region", { name: "Sidebar" }).getByRole("heading", { name: "Categories" })
// JavaScript
await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible()
await expect(page.getByRole("heading", { name: "Recent activity" })).toBeVisible()
await page.getByRole("region", { name: "Sidebar" }).getByRole("heading", { name: "Categories" })
7. 导航项
// TypeScript
// 主导航内的链接
await page.getByRole("navigation").getByRole("link", { name: "Pricing" }).click()
// 存在多个导航时,通过 aria-label 缩小范围
await page
.getByRole("navigation", { name: "Main menu" })
.getByRole("link", { name: "Pricing" })
.click()
// 面包屑导航
await page
.getByRole("navigation", { name: "Breadcrumb" })
.getByRole("link", { name: "Products" })
.click()
// JavaScript
await page.getByRole("navigation").getByRole("link", { name: "Pricing" }).click()
await page
.getByRole("navigation", { name: "Main menu" })
.getByRole("link", { name: "Pricing" })
.click()
await page
.getByRole("navigation", { name: "Breadcrumb" })
.getByRole("link", { name: "Products" })
.click()
8. 表格单元格
// TypeScript
// 在特定行中查找单元格
await page.getByRole("row", { name: /Jane Smith/ }).getByRole("cell", { name: "$120.00" })
// 点击特定行中的操作按钮
await page
.getByRole("row", { name: /Jane Smith/ })
.getByRole("button", { name: "Edit" })
.click()
// 使用 .filter() 进行复杂匹配来筛选行
const row = page.getByRole("row").filter({ hasText: "Pending" })
await row.getByRole("button", { name: "Approve" }).click()
// 验证表头存在
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible()
// JavaScript
await page.getByRole("row", { name: /Jane Smith/ }).getByRole("cell", { name: "$120.00" })
await page
.getByRole("row", { name: /Jane Smith/ })
.getByRole("button", { name: "Edit" })
.click()
const row = page.getByRole("row").filter({ hasText: "Pending" })
await row.getByRole("button", { name: "Approve" }).click()
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible()
9. 图片
// TypeScript
// 带有 alt 文本的图片
await expect(page.getByRole("img", { name: "Company logo" })).toBeVisible()
// 备选:getByAltText(结果相同,不太推荐)
await expect(page.getByAltText("Company logo")).toBeVisible()
// 卡片内的头像图片
await page
.locator("article")
.filter({ hasText: "Jane Smith" })
.getByRole("img", { name: "Jane Smith's avatar" })
// JavaScript
await expect(page.getByRole("img", { name: "Company logo" })).toBeVisible()
await expect(page.getByAltText("Company logo")).toBeVisible()
await page
.locator("article")
.filter({ hasText: "Jane Smith" })
.getByRole("img", { name: "Jane Smith's avatar" })
10. 自定义组件(无 ARIA 角色)
// TypeScript
// 第三方颜色选择器,无语义角色
await page.getByTestId("color-picker").click()
// 自定义拖放区域
await page.getByTestId("drop-zone").dispatchEvent("drop", { dataTransfer })
// 基于 Canvas 的图表
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
// JavaScript
await page.getByTestId("color-picker").click()
await page.getByTestId("drop-zone").dispatchEvent("drop", { dataTransfer })
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
在使用 getByTestId 之前,先问问自己:我能否为此组件添加 role 和 aria-label?如果可以,就这样做并使用 getByRole()。
11. 动态列表
// TypeScript
// 按文本筛选列表项
const item = page.getByRole("listitem").filter({ hasText: "Milk" })
await item.getByRole("button", { name: "Remove" }).click()
// 按子级定位器筛选
const card = page.locator(".product-card").filter({
has: page.getByText("Out of stock"),
})
await expect(card).toHaveCount(3)
// 统计列表项数量
await expect(page.getByRole("listitem")).toHaveCount(5)
// 遍历列表项进行复杂断言
for (const item of await page.getByRole("listitem").all()) {
await expect(item).toContainText("$")
}
// JavaScript
const item = page.getByRole("listitem").filter({ hasText: "Milk" })
await item.getByRole("button", { name: "Remove" }).click()
const card = page.locator(".product-card").filter({
has: page.getByText("Out of stock"),
})
await expect(card).toHaveCount(3)
await expect(page.getByRole("listitem")).toHaveCount(5)
for (const item of await page.getByRole("listitem").all()) {
await expect(item).toContainText("$")
}
12. 模态框与对话框
// TypeScript
// 等待对话框出现,然后在其中进行交互
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(dialog).toBeVisible()
await dialog.getByRole("button", { name: "Delete" }).click()
// 在对话框内填写表单
const modal = page.getByRole("dialog", { name: "Edit profile" })
await modal.getByLabel("Display name").fill("Jane")
await modal.getByRole("button", { name: "Save" }).click()
// 验证对话框已关闭
await expect(dialog).toBeHidden()
// JavaScript
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(dialog).toBeVisible()
await dialog.getByRole("button", { name: "Delete" }).click()
const modal = page.getByRole("dialog", { name: "Edit profile" })
await modal.getByLabel("Display name").fill("Jane")
await modal.getByRole("button", { name: "Save" }).click()
await expect(dialog).toBeHidden()
应避免的反模式
| 反模式 | 失败原因 | 应使用的方法 |
|---|---|---|
page.locator('.btn-primary') |
类名在重构和重新设计时会改变 | getByRole('button', { name: '...' }) |
page.locator('#email-input') |
ID 是实现细节,而非用户可见内容 | getByLabel('Email') |
page.locator('div > form > input:first-child') |
任何结构变化都会破坏该选择器 | getByLabel('...') 或 getByRole('textbox', { name: '...' }) |
page.locator('[data-testid="submit"]') |
对测试 ID 使用原生 CSS——应使用内置方法 | getByTestId('submit') |
对按钮使用 page.getByText('Submit') |
匹配任何包含该文本的元素,而非仅限按钮 | getByRole('button', { name: 'Submit' }) |
page.locator('button').nth(2) |
基于索引——顺序改变时就会失效 | getByRole('button', { name: '...' }) |
范围限定策略:当多个元素匹配时
当定位器匹配到多个元素时,应缩小范围而非使用 nth():
// 错误:脆弱的索引
page.getByRole("button", { name: "Edit" }).nth(0)
// 正确:范围限定到父级区域
page.getByRole("region", { name: "Billing" }).getByRole("button", { name: "Edit" })
// 正确:范围限定到表格行
page.getByRole("row", { name: /Order #1234/ }).getByRole("button", { name: "Edit" })
// 正确:使用 filter 限定范围
page.locator("article").filter({ hasText: "Draft" }).getByRole("button", { name: "Edit" })