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

29 KiB
Raw Permalink Blame History

定位器

何时使用:每次需要在页面上查找元素时。在考虑使用 CSS 或 XPath 之前,请从这里入手。 前置知识core/configuration.md

快速参考

// 优先级顺序——优先使用第一个能生效的:
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

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

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

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

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

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

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 渲染组件、复杂数据网格或第三方小部件。 何时避免:当任何语义定位器(getByRolegetByLabelgetByText)都能识别该元素时。测试 ID 对用户和辅助技术不可见。

playwright.config 中配置一次属性名:

TypeScript

// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    testIdAttribute: "data-testid", // 默认值;根据你的代码库修改
  },
})
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

// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  use: {
    testIdAttribute: "data-testid",
  },
})
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

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

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

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

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

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

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

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

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

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

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 拒绝替你选择一个。

// 错误: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 内部,或被遮挡/隐藏。

// 检查元素是否在 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 角色与你预期的不符,或者它没有角色。

// 调试:检查可访问性树
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()

相关