641 lines
29 KiB
Markdown
641 lines
29 KiB
Markdown
# 定位器
|
||
|
||
> **何时使用**:每次需要在页面上查找元素时。在考虑使用 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)——面向国际化应用的定位器策略
|