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

641 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 定位器
> **何时使用**:每次需要在页面上查找元素时。在考虑使用 CSS 或 XPath 之前,请从这里入手。
> **前置知识**[core/configuration.md](configuration.md)
## 快速参考
```typescript
// 优先级顺序——优先使用第一个能生效的:
page.getByRole("button", { name: "Submit" }) // 1. 角色(默认)
page.getByLabel("Email address") // 2. 标签(表单字段)
page.getByText("Welcome back") // 3. 文本(非交互元素)
page.getByPlaceholder("Search...") // 4. 占位符
page.getByAltText("Company logo") // 5. 替代文本(图片)
page.getByTitle("Close dialog") // 6. Title 属性
page.getByTestId("checkout-summary") // 7. 测试 ID(最后一个语义选项)
page.locator("css=.legacy-widget >> internal:role=button") // 8. CSS/XPath(最后手段)
```
## 模式
### 基于角色的定位器(默认选择)
**何时使用**:始终如此。这是每个元素的起点。
**何时避免**:当元素没有 ARIA 角色,并且添加角色不在你的控制范围内时。
基于角色的定位器反映了辅助技术识别页面的方式。它们在重构、类名重命名和组件库替换后仍能正常工作。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("role-based locators cover most UI elements", async ({ page }) => {
await page.goto("/dashboard")
// 按钮——匹配 <button>、<input type="submit">、role="button"
await page.getByRole("button", { name: "Save changes" }).click()
// 链接——匹配 <a href>
await page.getByRole("link", { name: "View profile" }).click()
// 标题——使用 level 定位特定的 h1-h6
await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible()
// 文本输入框——通过可访问名称(标签关联)匹配
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
// 复选框和单选按钮
await page.getByRole("checkbox", { name: "Remember me" }).check()
await page.getByRole("radio", { name: "Monthly billing" }).click()
// 下拉框——<select> 元素
await page.getByRole("combobox", { name: "Country" }).selectOption("US")
// 导航区域
const nav = page.getByRole("navigation", { name: "Main" })
await expect(nav.getByRole("link", { name: "Settings" })).toBeVisible()
// 表格
const table = page.getByRole("table", { name: "Recent orders" })
await expect(table.getByRole("row")).toHaveCount(5)
// 对话框
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await dialog.getByRole("button", { name: "Delete" }).click()
// 精确匹配——防止 "Log" 匹配到 "Log out"
await page.getByRole("button", { name: "Log", exact: true }).click()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("role-based locators cover most UI elements", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Save changes" }).click()
await page.getByRole("link", { name: "View profile" }).click()
await expect(page.getByRole("heading", { name: "Dashboard", level: 1 })).toBeVisible()
await page.getByRole("textbox", { name: "Email" }).fill("user@example.com")
await page.getByRole("checkbox", { name: "Remember me" }).check()
await page.getByRole("radio", { name: "Monthly billing" }).click()
await page.getByRole("combobox", { name: "Country" }).selectOption("US")
const nav = page.getByRole("navigation", { name: "Main" })
await expect(nav.getByRole("link", { name: "Settings" })).toBeVisible()
const dialog = page.getByRole("dialog", { name: "Confirm deletion" })
await dialog.getByRole("button", { name: "Delete" }).click()
await page.getByRole("button", { name: "Log", exact: true }).click()
})
```
### 基于标签的定位器
**何时使用**:定位拥有关联 `<label>` 元素或 `aria-label` 的表单字段时。
**何时避免**:当元素不是表单控件时。改用 `getByRole` 配合可访问名称——它也能覆盖标签。
`getByLabel` 是表单字段的快捷方式。它匹配 `<label for="">`、包裹式 `<label>` 以及 `aria-label` / `aria-labelledby`
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("fill a registration form using labels", async ({ page }) => {
await page.goto("/register")
await page.getByLabel("First name").fill("Jane")
await page.getByLabel("Last name").fill("Doe")
await page.getByLabel("Email address").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
await page.getByLabel("Confirm password").fill("s3cure!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("fill a registration form using labels", async ({ page }) => {
await page.goto("/register")
await page.getByLabel("First name").fill("Jane")
await page.getByLabel("Last name").fill("Doe")
await page.getByLabel("Email address").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("s3cure!Pass")
await page.getByLabel("Confirm password").fill("s3cure!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible()
})
```
### 基于文本的定位器
**何时使用**:定位非交互内容——状态消息、段落、横幅、表单外部的标签。
**何时避免**:当元素是按钮、链接、标题或表单字段时。改用 `getByRole`
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("verify text content on the page", async ({ page }) => {
await page.goto("/order/confirmation")
// 子串匹配(默认)
await expect(page.getByText("Order confirmed")).toBeVisible()
// 精确匹配——当子串匹配到多个元素时使用
await expect(page.getByText("Order #12345", { exact: true })).toBeVisible()
// 正则匹配——用于动态内容模式
await expect(page.getByText(/Order #\d+/)).toBeVisible()
// 不要对按钮或链接使用 getByText:
// 错误:page.getByText('Submit')
// 正确:page.getByRole('button', { name: 'Submit' })
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("verify text content on the page", async ({ page }) => {
await page.goto("/order/confirmation")
await expect(page.getByText("Order confirmed")).toBeVisible()
await expect(page.getByText("Order #12345", { exact: true })).toBeVisible()
await expect(page.getByText(/Order #\d+/)).toBeVisible()
})
```
### 测试 ID 定位器
**何时使用**:当没有任何语义定位器可用时——元素没有可访问角色、标签或稳定的文本。常见于自定义 Canvas 渲染组件、复杂数据网格或第三方小部件。
**何时避免**:当任何语义定位器(`getByRole``getByLabel``getByText`)都能识别该元素时。测试 ID 对用户和辅助技术不可见。
`playwright.config` 中配置一次属性名:
**TypeScript**
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
use: {
testIdAttribute: "data-testid", // 默认值;根据你的代码库修改
},
})
```
```typescript
import { test, expect } from "@playwright/test"
test("interact with a custom widget using test IDs", async ({ page }) => {
await page.goto("/analytics")
// 仅在图表组件没有暴露任何可访问角色时使用
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
await chart.click({ position: { x: 150, y: 75 } })
await expect(page.getByTestId("chart-tooltip")).toContainText("$12,400")
})
```
**JavaScript**
```javascript
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
use: {
testIdAttribute: "data-testid",
},
})
```
```javascript
const { test, expect } = require("@playwright/test")
test("interact with a custom widget using test IDs", async ({ page }) => {
await page.goto("/analytics")
const chart = page.getByTestId("revenue-chart")
await expect(chart).toBeVisible()
await chart.click({ position: { x: 150, y: 75 } })
await expect(page.getByTestId("chart-tooltip")).toContainText("$12,400")
})
```
### CSS/XPath——最后手段
**何时使用**:当你对标记完全没有任何控制权,没有测试 ID,没有可访问名称,也无法添加它们时。适用于使用生成类名且无语义 HTML 的遗留应用。
**何时避免**:当任何其他定位器类型能生效时。始终如此。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("legacy app with no semantic markup", async ({ page }) => {
await page.goto("/legacy-admin")
// CSS——优先使用简短、结构化的选择器,而非脆弱的类名链
await page.locator('table.report-grid td:has-text("Overdue")').first().click()
// XPath——仅在 CSS 无法表达查询时使用(例如,文本结合祖先遍历)
await page.locator('xpath=//td[contains(text(),"Overdue")]/ancestor::tr//button').click()
// 结合 CSS 与 Playwright 伪选择器以增强鲁棒性
await page.locator('.sidebar >> role=button[name="Expand"]').click()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("legacy app with no semantic markup", async ({ page }) => {
await page.goto("/legacy-admin")
await page.locator('table.report-grid td:has-text("Overdue")').first().click()
await page.locator('xpath=//td[contains(text(),"Overdue")]/ancestor::tr//button').click()
await page.locator('.sidebar >> role=button[name="Expand"]').click()
})
```
### 定位器链式调用与过滤
**何时使用**:当单个定位器匹配到多个元素,你需要通过上下文、内容或位置来缩小范围时。
**何时避免**:当直接使用 `getByRole` 配合 `name` 已经能唯一标识元素时。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("chaining and filtering locators", async ({ page }) => {
await page.goto("/products")
// 链式调用:将一个定位器的作用域限定在另一个之内
const productCard = page.getByRole("listitem").filter({ hasText: "Running Shoes" })
await productCard.getByRole("button", { name: "Add to cart" }).click()
// 通过子定位器过滤——比 hasText 更精确
const row = page.getByRole("row").filter({
has: page.getByRole("cell", { name: "Premium Plan" }),
})
await row.getByRole("button", { name: "Upgrade" }).click()
// 使用 hasNot 过滤——排除包含某个子元素的元素
const availableItems = page.getByRole("listitem").filter({
hasNot: page.getByText("Sold out"),
})
await expect(availableItems).toHaveCount(3)
// 使用 hasNotText 过滤——按文本内容排除
const nonFeatured = page.getByRole("listitem").filter({
hasNotText: "Featured",
})
// 位置定位:nth、first、last——谨慎使用,仅在顺序稳定时使用
const thirdItem = page.getByRole("listitem").nth(2) // 从 0 开始索引
const firstItem = page.getByRole("listitem").first()
const lastItem = page.getByRole("listitem").last()
// 组合多个过滤器
const activeAdminRow = page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: "Admin" }) })
.filter({ has: page.getByText("Active") })
await expect(activeAdminRow).toHaveCount(1)
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("chaining and filtering locators", async ({ page }) => {
await page.goto("/products")
const productCard = page.getByRole("listitem").filter({ hasText: "Running Shoes" })
await productCard.getByRole("button", { name: "Add to cart" }).click()
const row = page.getByRole("row").filter({
has: page.getByRole("cell", { name: "Premium Plan" }),
})
await row.getByRole("button", { name: "Upgrade" }).click()
const availableItems = page.getByRole("listitem").filter({
hasNot: page.getByText("Sold out"),
})
await expect(availableItems).toHaveCount(3)
const thirdItem = page.getByRole("listitem").nth(2)
const firstItem = page.getByRole("listitem").first()
const lastItem = page.getByRole("listitem").last()
const activeAdminRow = page
.getByRole("row")
.filter({ has: page.getByRole("cell", { name: "Admin" }) })
.filter({ has: page.getByText("Active") })
await expect(activeAdminRow).toHaveCount(1)
})
```
### 框架定位器
**何时使用**:与 `<iframe>``<frame>` 元素内部的内容交互时——支付小部件、嵌入式编辑器、第三方小部件。
**何时避免**:当内容在主框架或 Shadow DOM 中时(改用穿透方式)。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("interact with content inside an iframe", async ({ page }) => {
await page.goto("/checkout")
// 定位 iframe,然后在其内部使用常规定位器
const paymentFrame = page.frameLocator('iframe[title="Payment"]')
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
await paymentFrame.getByLabel("Expiration").fill("12/28")
await paymentFrame.getByLabel("CVC").fill("123")
await paymentFrame.getByRole("button", { name: "Pay" }).click()
// 嵌套 iframe——链式调用 frameLocator
const nestedFrame = page.frameLocator("#outer-frame").frameLocator("#inner-frame")
await expect(nestedFrame.getByText("Payment confirmed")).toBeVisible()
// 按第 N 个索引定位框架——当没有更好的选择器时
const secondFrame = page.frameLocator("iframe").nth(1)
await expect(secondFrame.getByRole("heading")).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("interact with content inside an iframe", async ({ page }) => {
await page.goto("/checkout")
const paymentFrame = page.frameLocator('iframe[title="Payment"]')
await paymentFrame.getByLabel("Card number").fill("4242424242424242")
await paymentFrame.getByLabel("Expiration").fill("12/28")
await paymentFrame.getByLabel("CVC").fill("123")
await paymentFrame.getByRole("button", { name: "Pay" }).click()
const nestedFrame = page.frameLocator("#outer-frame").frameLocator("#inner-frame")
await expect(nestedFrame.getByText("Payment confirmed")).toBeVisible()
const secondFrame = page.frameLocator("iframe").nth(1)
await expect(secondFrame.getByRole("heading")).toBeVisible()
})
```
### Shadow DOM 穿透
**何时使用**:定位使用 Shadow DOM 的 Web 组件内部的元素时(自定义元素、设计系统组件、Salesforce Lightning)。
**何时避免**:当元素在常规 DOM 或 iframe 中时。
Playwright 使用 `locator()` 自动穿透开放的 Shadow DOM。`getByRole` / `getByText` 系列默认也能穿透 shadow root。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("interact with Shadow DOM elements", async ({ page }) => {
await page.goto("/design-system-demo")
// getByRole 自动穿透开放的 Shadow DOM——正常使用即可
await page.getByRole("button", { name: "Toggle menu" }).click()
// 使用 CSS 的 locator() 默认也能穿透 shadow root
await page.locator("my-dropdown").getByRole("option", { name: "Settings" }).click()
// 链式进入嵌套的 Shadow DOM
await page.locator("my-app").locator("my-sidebar").getByRole("link", { name: "Profile" }).click()
// 如果你明确需要非穿透行为(极少见),使用 css=light/
// await page.locator('css:light=.outer-only').click();
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("interact with Shadow DOM elements", async ({ page }) => {
await page.goto("/design-system-demo")
await page.getByRole("button", { name: "Toggle menu" }).click()
await page.locator("my-dropdown").getByRole("option", { name: "Settings" }).click()
await page.locator("my-app").locator("my-sidebar").getByRole("link", { name: "Profile" }).click()
})
```
### 动态内容——等待元素
**何时使用**:当元素在 API 调用、动画、懒加载或路由切换之后才出现时。
**何时避免**:当元素已经在页面上时。Playwright 对操作会自动等待,因此显式等待很少需要。
永远不要使用 `page.waitForTimeout()`。改用自动等待断言或显式事件等待。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test("handle dynamic content without manual waits", async ({ page }) => {
await page.goto("/search")
// 自动等待:click()、fill() 等操作会自动等待元素出现
await page.getByRole("textbox", { name: "Search" }).fill("playwright")
await page.getByRole("button", { name: "Search" }).click()
// Web 优先断言:自动重试直到超时(默认 5 秒)
await expect(page.getByRole("listitem")).toHaveCount(10)
// 等待异步操作后出现特定元素
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
// 等待加载指示器消失
await expect(page.getByRole("progressbar")).toBeHidden()
// 等待网络依赖内容:先等待响应,再断言
const responsePromise = page.waitForResponse("**/api/search*")
await page.getByRole("button", { name: "Load more" }).click()
await responsePromise
await expect(page.getByRole("listitem")).toHaveCount(20)
// 等待导航后的 URL 变化
await page.getByRole("link", { name: "First result" }).click()
await page.waitForURL("**/results/**")
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test("handle dynamic content without manual waits", async ({ page }) => {
await page.goto("/search")
await page.getByRole("textbox", { name: "Search" }).fill("playwright")
await page.getByRole("button", { name: "Search" }).click()
await expect(page.getByRole("listitem")).toHaveCount(10)
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible()
await expect(page.getByRole("progressbar")).toBeHidden()
const responsePromise = page.waitForResponse("**/api/search*")
await page.getByRole("button", { name: "Load more" }).click()
await responsePromise
await expect(page.getByRole("listitem")).toHaveCount(20)
await page.getByRole("link", { name: "First result" }).click()
await page.waitForURL("**/results/**")
await expect(page.getByRole("heading", { level: 1 })).toBeVisible()
})
```
## 决策指南
| 元素类型 | 推荐定位器 | 示例 | 原因 |
| ------------------ | ---------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- |
| 按钮 | `getByRole('button', { name })` | `getByRole('button', { name: 'Submit' })` | 匹配 `<button>``<input type="submit">``role="button"` |
| 链接 | `getByRole('link', { name })` | `getByRole('link', { name: 'Home' })` | 匹配任何 `<a href>`,无论样式如何 |
| 文本输入框 | `getByRole('textbox', { name })` | `getByRole('textbox', { name: 'Email' })` | 通过可访问名称(标签)匹配 |
| 密码输入框 | `getByLabel()` | `getByLabel('Password')` | 密码字段没有独特角色;标签是最佳匹配 |
| 复选框 | `getByRole('checkbox', { name })` | `getByRole('checkbox', { name: 'Agree' })` | 同时推荐使用 `.check()` / `.uncheck()` 而非 `.click()` |
| 单选按钮 | `getByRole('radio', { name })` | `getByRole('radio', { name: 'Express' })` | 使用 `getByRole('radiogroup')` 对单选按钮分组 |
| 选择框/下拉框 | `getByRole('combobox', { name })` | `getByRole('combobox', { name: 'Country' })` | 原生 `<select>` 映射为 combobox 角色 |
| 自定义下拉框 | `getByRole('listbox')` + `getByRole('option')` | 点击触发器,然后 `getByRole('option', { name })` | 自定义下拉框的 ARIA listbox 模式 |
| 标题 | `getByRole('heading', { name, level })` | `getByRole('heading', { name: 'Dashboard', level: 2 })` | 使用 `level` 区分 h1-h6 |
| 表格 | `getByRole('table', { name })` | `getByRole('table', { name: 'Users' })` | 配合 `getByRole('row')``getByRole('cell')` 链式使用 |
| 表格行 | `getByRole('row').filter({ has })` | `.filter({ has: getByRole('cell', { name: 'Jane' }) })` | 通过单元格内容过滤行 |
| 导航 | `getByRole('navigation', { name })` | `getByRole('navigation', { name: 'Main' })` | 匹配带 `aria-label``<nav>` |
| 对话框/弹窗 | `getByRole('dialog', { name })` | `getByRole('dialog', { name: 'Confirm' })` | 将所有对话框交互限定在此范围内 |
| 标签页 | `getByRole('tab', { name })` | `getByRole('tab', { name: 'Settings' })` | 配合 `getByRole('tabpanel')` 使用 |
| 图片 | `getByAltText()` | `getByAltText('User avatar')` | 匹配 `alt` 属性 |
| 表单字段(任意) | `getByLabel()` | `getByLabel('Date of birth')` | 当角色不明确时的备选方案(日期选择器、自定义输入框) |
| 静态文本 | `getByText()` | `getByText('No results found')` | 仅限非交互内容 |
| 无语义标记 | `getByTestId()` | `getByTestId('sparkline-chart')` | 使用 CSS 之前的最后一个语义选项 |
| iframe 内容 | `frameLocator()` 然后任意定位器 | `frameLocator('#payment').getByLabel('Card')` | 跨框架访问时必需 |
| Shadow DOM | `getByRole()` / `locator()` | 自动生效 | Playwright 默认穿透开放的 shadow root |
## 反模式
| 不要这样做 | 问题 | 应该这样做 |
| ----------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
| `page.locator('.btn-primary')` | 当 CSS 类名更改时失效(重命名、CSS Modules、Tailwind | `page.getByRole('button', { name: 'Save' })` |
| `page.locator('#submit-btn')` | ID 是实现细节;通常由工具自动生成 | `page.getByRole('button', { name: 'Submit' })` |
| `page.locator('div > span:nth-child(3)')` | 任何 DOM 结构调整都会导致失效 | `page.getByText('Expected content')``getByTestId()` |
| `page.locator('xpath=//div[@class="form"]//input[2]')` | 脆弱、不可读、依赖位置 | `page.getByLabel('Last name')` |
| 对按钮使用 `page.getByText('Submit')` | 文本定位器不验证元素是否可交互 | `page.getByRole('button', { name: 'Submit' })` |
| 在动态列表上使用 `page.locator('.item').nth(0)` | 当项目被添加/删除/重新排序时,索引会变化 | `.filter({ hasText: 'Specific item' })` |
| `page.getByText('Aceptar')` 硬编码国际化文本 | 当语言环境变化时失效 | `page.getByRole('button', { name: /accept/i })``getByTestId('confirm-btn')` |
| `await page.waitForTimeout(3000)` | 任意延迟;在快环境中太慢,在慢环境中太短 | `await expect(locator).toBeVisible()` |
| `page.locator('.card').locator('.card-title').locator('a')` | 深度 CSS 链式调用在任何结构变更时都会失效 | `page.getByRole('link', { name: 'Card title text' })` |
| `page.$('selector')`ElementHandle API | 返回快照而非自动等待;已弃用的模式 | `page.locator('selector')`——定位器是惰性的且会自动等待 |
| `page.locator('text=Click here')` | 遗留的文本选择器语法 | `page.getByText('Click here')` 或带 name 的 `getByRole` |
| 对一个元素依次使用多个定位器 | 链中的每次 `locator()` 调用都会重新开始搜索 | 保存为变量:`const btn = page.getByRole('button', { name: 'Save' })` |
## 故障排除
### "strict mode violation"——定位器匹配到多个元素
**原因**:定位器不够具体,Playwright 拒绝替你选择一个。
```typescript
// 错误:locator.click: strict mode violation, getByRole('button') 解析到 5 个元素
await page.getByRole("button").click() // 太宽泛
// 修复 1:添加名称过滤器
await page.getByRole("button", { name: "Save" }).click()
// 修复 2:在父元素内限定范围
await page.getByRole("dialog").getByRole("button", { name: "Save" }).click()
// 修复 3:当名称之间存在子串关系时,使用精确匹配
await page.getByRole("button", { name: "Save", exact: true }).click()
// 修复 4:使用 filter 缩小范围
await page.getByRole("button").filter({ hasText: "Save draft" }).click()
// 调试:查看匹配到了什么
console.log(await page.getByRole("button").all()) // 列出所有匹配项
```
### 元素存在但定位器超时
**原因**:元素位于 iframe、Shadow DOM 内部,或被遮挡/隐藏。
```typescript
// 检查元素是否在 iframe 内
const frame = page.frameLocator("iframe")
await frame.getByRole("button", { name: "Submit" }).click()
// 检查元素是否在 Shadow DOM 中——getByRole 自动穿透,
// 但 page.$() 和 CSS 选择器可能无法穿透。改用 getByRole。
// 检查元素是否隐藏(display: none、visibility: hidden、opacity: 0
// 使用 toBeVisible() 确认,或如果隐藏是预期行为则使用 toBeAttached()
await expect(page.getByRole("button", { name: "Submit" })).toBeAttached()
```
### `getByRole` 找不到元素
**原因**:元素的隐式 ARIA 角色与你预期的不符,或者它没有角色。
```typescript
// 调试:检查可访问性树
const snapshot = await page.accessibility.snapshot()
console.log(JSON.stringify(snapshot, null, 2))
// 常见不匹配:
// - <div onclick="..."> 没有按钮角色 → 添加 role="button" 或使用 <button>
// - 没有标签的 <input type="text"> 没有可访问名称 → 添加 <label> 或 aria-label
// - 不带 href 的 <a> 没有链接角色 → 添加 href 或 role="link"
// 备选链:依次尝试 getByLabel → getByText → getByTestId → locator()
```
## 相关
- [core/locator-strategy.md](locator-strategy.md)——在项目层面选择定位器策略的深度决策框架
- [core/assertions-and-waiting.md](assertions-and-waiting.md)——将定位器与 Web 优先断言配合使用
- [pom/page-object-model.md](../pom/page-object-model.md)——将定位器封装在页面对象中
- [core/iframes-and-shadow-dom.md](iframes-and-shadow-dom.md)——高级 iframe 和 Shadow DOM 模式
- [core/i18n-and-localization.md](i18n-and-localization.md)——面向国际化应用的定位器策略