---
title: Playwright 定位器完全指南 — 選出打不壞的 selector
description: getByRole / getByLabel / getByText / getByTestId 怎麼選、CSS 與 XPath 何時用、auto-waiting 和 locator 的關係，以及 nth 與寫死 class 這類會讓測試時好時壞的反模式。
category: automation
tags: [playwright, locators, selector, 自動化]
date: 2026-08-14
faq:
  - q: Playwright 的 getByRole 和 getByTestId 該優先用哪個？
    a: 優先用 getByRole。getByRole 跟著無障礙樹（accessibility tree）走，反映使用者真正看到與操作的元素，UI 換 class、換排版都不會壞，還順便驗證了無障礙屬性。getByTestId 是保底手段，只有在沒有 role、沒有 label、文字又會變動時才用，而且要跟前端團隊約好 data-testid 命名規範，否則會變成另一種寫死。
  - q: Playwright locator 需要自己寫 wait 或 sleep 嗎？
    a: 不需要。locator 是惰性（lazy）的，要到真正 action（click、fill）或 assertion（expect().toBeVisible()）時才去找元素，並內建 auto-waiting，自動等到元素可見、enabled、stable 才動作。你幾乎不該再寫 waitForTimeout。真的要等特定條件就用 web-first assertion 或 waitFor()，不要用固定秒數的 sleep。
  - q: 為什麼用 nth(2) 或 .css-1a2b3c 選元素常常時好時壞？
    a: 因為這兩種都綁在「會變的東西」上。nth 綁 DOM 順序，多插一個元素、非同步載入順序一變就選錯；CSS-in-JS 的雜湊 class（如 .css-1a2b3c）每次 build 都可能重新產生。它們今天能過只是剛好，屬於典型 flaky 反模式。改用 getByRole 搭 name、或用 filter() 依內容縮小範圍會穩很多。
  - q: locator 選到多個元素（strict mode violation）怎麼辦？
    a: Playwright 預設嚴格模式，一個 locator 對到多個元素、又要做單一 action 時會直接報 strict mode violation，這是好事，逼你講清楚要哪一個。正解是縮小範圍：先用 getByRole 之類定位容器再往下找、用 filter({ hasText }) 依內容篩、或 getByRole('row').filter() 這種組合，而不是無腦補一個 .first()。
---

會不會寫 Playwright，八成看你會不會**選元素**。API 就那幾個，難的是選出一個「UI 改版也不會壞」的 locator。這篇只講這一件事，講深。

如果你還沒跑過第一支測試，先看 Playwright 入門那篇；這篇假設你已經會跑，想把 selector 從「能動」升級到「打不壞」。

## 先搞懂 locator 是什麼

很多人把 `page.locator(...)` 當成「馬上去 DOM 抓元素」，這是誤會。**locator 是惰性的**：它只是一段「怎麼找這個元素」的描述，要到你真的 `click()`、`fill()` 或 `expect()` 時才去解析。

```typescript
// 這行不會碰 DOM，只是描述
const submit = page.getByRole('button', { name: '送出' });

// 到這裡才真的找元素 + auto-wait
await submit.click();
```

這個「惰性」特性很重要，它是 auto-waiting 能運作的原因（後面會講），也是為什麼你可以先宣告 locator、之後重複用都不會抓到過期的舊 DOM node。

## 定位器優先順序（背起來）

Playwright 官方推薦的順序，本質是「**越接近使用者感知的，越穩**」：

| 優先 | 方法 | 適用 | 為什麼穩 |
|------|------|------|----------|
| 1 | `getByRole` | 按鈕、連結、標題、輸入框 | 綁無障礙樹，改 class/排版都不影響 |
| 2 | `getByLabel` | 表單欄位 | 綁 `<label>`，跟使用者「看標籤填欄位」一致 |
| 3 | `getByPlaceholder` | 沒 label 的輸入框 | 綁 placeholder 文字 |
| 4 | `getByText` | 純文字內容、提示訊息 | 綁看得到的字 |
| 5 | `getByTestId` | 上面都不行 | 綁專用屬性，但需團隊約定 |
| — | `locator('css=' / 'xpath=')` | 最後手段 | 綁實作細節，最易壞 |

### getByRole：預設就用它

`getByRole` 對到的是元素的**語意角色**，不是標籤名稱。`<button>`、`<a role="button">`、`<input type="submit">` 都是 `button` role。

```typescript
// 用 name 過濾，name 對應無障礙名稱（通常是可見文字或 aria-label）
await page.getByRole('button', { name: '加入購物車' }).click();

// name 支援正規表達式，處理動態文字
await page.getByRole('link', { name: /訂單 #\d+/ }).click();

// 精準比對，避免「送出」比對到「送出並繼續」
await page.getByRole('button', { name: '送出', exact: true }).click();
```

順帶一提：如果 `getByRole` 選不到，常常代表**前端無障礙寫得不好**（缺 label、用 div 假裝按鈕）。這其實是你順手抓到的 a11y bug，值得回報。

### getByLabel：表單欄位首選

```typescript
await page.getByLabel('電子郵件').fill('test@example.com');
await page.getByLabel('我同意條款').check();
```

只要 `<label for>` 或包裹式 label 有接對，這比用 `#email` 之類 id 穩，因為 id 是實作、label 是使用者看到的。

### getByTestId：保底，不是首選

```html
<span data-testid="cart-count">3</span>
```

```typescript
await expect(page.getByTestId('cart-count')).toHaveText('3');
```

用 testid 的前提是**跟前端約好規範**：命名一致、不隨便刪、不拿來當 CSS hook。否則它只是換一種寫死。什麼時候該用？純數字/圖示、沒有穩定文字或 role 的元素（例如一個只有顏色的狀態點）。

## CSS vs XPath：能不用就不用

`locator('css=...')` 和 `locator('xpath=...')` 不是不能用，而是它們綁 DOM 結構與 class，這些是**最容易變的東西**。

```typescript
// ❌ CSS-in-JS 雜湊，每次 build 都可能變
page.locator('.css-1a2b3c');

// ❌ XPath 綁死結構，插一層 div 就壞
page.locator('xpath=//div/div[3]/button');

// 🟡 真的只剩 CSS 可用時，綁「穩定的語意屬性」而非樣式 class
page.locator('[data-state="open"]');
page.locator('input[name="coupon"]');
```

判斷準則：**這個 selector 綁的東西，改版時會不會動？** 綁 `name` 屬性、`data-*` 語意屬性，比綁 `.btn.btn-lg.rounded` 這種樣式 class 安全得多。XPath 幾乎沒有非用不可的場景（真要往上找父層才勉強考慮）。

## locator 和 auto-waiting 的關係

這是 locator 最值錢的地方。每次 action 前，Playwright 會自動等元素通過一系列 **actionability 檢查**：

| 檢查 | 意思 |
|------|------|
| Attached | 元素在 DOM 裡 |
| Visible | 有非空的 bounding box、非 `display:none` |
| Stable | 動畫停了、位置不再移動 |
| Enabled | 不是 disabled |
| Receives events | 沒被別的元素蓋住 |

```typescript
// 不用寫任何 sleep
// Playwright 會自動等按鈕出現、動畫停、可點擊，才點下去
await page.getByRole('button', { name: '結帳' }).click();
```

`expect()` 的 web-first assertion 也會 **auto-retry**，預設在 timeout 內反覆檢查：

```typescript
// 非同步載入的內容，不用自己輪詢
await expect(page.getByText('訂單已成立')).toBeVisible({ timeout: 10000 });
```

**關鍵觀念**：因為 locator 惰性 + auto-wait，你應該把 locator 想成「一個持續有效的查詢」，而不是「某一刻的 DOM 快照」。所以下面這種寫法才安全：

```typescript
const row = page.getByRole('row', { name: /張三/ });
await row.getByRole('button', { name: '刪除' }).click();
await expect(row).toBeHidden(); // row 重新解析，正確反映刪除後狀態
```

## 縮小範圍：filter、鏈式、getByRole 巢狀

畫面上有 10 個「刪除」按鈕怎麼辦？不要用 `nth`，用**內容**縮小。

```typescript
// ❌ 綁順序，多一列就選錯
await page.getByRole('button', { name: '刪除' }).nth(2).click();

// ✅ 先鎖到那一列，再點該列的刪除
await page
  .getByRole('row')
  .filter({ hasText: 'INV-2026-0042' })
  .getByRole('button', { name: '刪除' })
  .click();
```

`filter` 常用兩招：

```typescript
// 依內含文字
page.getByRole('listitem').filter({ hasText: '已完成' });

// 依內含另一個 locator
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: '編輯' }) });
```

## Strict mode：報錯是保護你

Playwright 預設 strict mode。當一個 locator 對到**多個**元素、又要做單一 action 時，它直接丟 error 而不是隨便選一個：

```
Error: strict mode violation: locator('button') resolved to 3 elements
```

這是好事，逼你講清楚要哪個。**別急著補 `.first()`** 蓋掉問題，先想「為什麼會多個」，再用 role + name 或 filter 精確定位。真的合理有多個（例如就是要驗證有 3 筆）才用 `.all()` 迭代或 `count()` 斷言。

## 常見錯誤

1. **用 `nth()` 定位業務元素** — 綁順序 = 綁時間。非同步渲染順序一變就選到別人，是 flaky 頭號來源。改用內容 filter。
2. **綁 CSS-in-JS 雜湊 class** — `.css-1a2b3c`、`.jsx-928374` 這種 build 一次變一次，等於沒綁。
3. **在 locator 前面自己加 sleep** — `waitForTimeout(2000)` 治標。慢是因為在等某個狀態，那就用 `expect().toBeVisible()` 等那個狀態，快又穩。
4. **把 testid 當首選** — 全站 `getByTestId` 會讓測試變成前端實作的鏡子，重構就大規模壞，也錯過用 role 順手驗 a11y 的機會。
5. **用 `textContent()` 自己比字串** — `expect(await el.textContent()).toBe('x')` 是一次性快照，不 retry，非同步內容必 flaky。用 `expect(el).toHaveText('x')` 才有 auto-retry。
6. **一條 XPath 打天下** — `//div[2]/span[1]` 只要 DOM 微調就全崩，維護成本極高。

## 一句話總結

選 selector 的黃金律：**綁使用者看得到、感知得到的東西（role、label、text），不要綁實作細節（class、DOM 順序）。** 做到這點，前端改版你的測試不會跟著陪葬，這才是自動化真正省時間的地方。
