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

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

先搞懂 locator 是什麼

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

// 這行不會碰 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。

// 用 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:表單欄位首選

await page.getByLabel('電子郵件').fill('[email protected]');
await page.getByLabel('我同意條款').check();

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

getByTestId:保底,不是首選

<span data-testid="cart-count">3</span>
await expect(page.getByTestId('cart-count')).toHaveText('3');

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

CSS vs XPath:能不用就不用

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

// ❌ 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 沒被別的元素蓋住
// 不用寫任何 sleep
// Playwright 會自動等按鈕出現、動畫停、可點擊,才點下去
await page.getByRole('button', { name: '結帳' }).click();

expect() 的 web-first assertion 也會 auto-retry,預設在 timeout 內反覆檢查:

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

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

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

縮小範圍:filter、鏈式、getByRole 巢狀

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

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

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

filter 常用兩招:

// 依內含文字
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 前面自己加 sleepwaitForTimeout(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 順序)。 做到這點,前端改版你的測試不會跟著陪葬,這才是自動化真正省時間的地方。