Files
2026-07-13 21:36:47 +08:00

21 KiB
Raw Permalink Blame History

选择定位策略

何时使用: 当需要决定对某个元素使用哪种 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
  • 支持按 namelevelcheckedpressedexpandedselected 进行筛选

缺点

  • 要求元素具有有效的 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 之前,先问问自己:我能否为此组件添加 rolearia-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" })

相关链接