會不會寫 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() 斷言。
常見錯誤
- 用
nth()定位業務元素 — 綁順序 = 綁時間。非同步渲染順序一變就選到別人,是 flaky 頭號來源。改用內容 filter。 - 綁 CSS-in-JS 雜湊 class —
.css-1a2b3c、.jsx-928374這種 build 一次變一次,等於沒綁。 - 在 locator 前面自己加 sleep —
waitForTimeout(2000)治標。慢是因為在等某個狀態,那就用expect().toBeVisible()等那個狀態,快又穩。 - 把 testid 當首選 — 全站
getByTestId會讓測試變成前端實作的鏡子,重構就大規模壞,也錯過用 role 順手驗 a11y 的機會。 - 用
textContent()自己比字串 —expect(await el.textContent()).toBe('x')是一次性快照,不 retry,非同步內容必 flaky。用expect(el).toHaveText('x')才有 auto-retry。 - 一條 XPath 打天下 —
//div[2]/span[1]只要 DOM 微調就全崩,維護成本極高。
一句話總結
選 selector 的黃金律:綁使用者看得到、感知得到的東西(role、label、text),不要綁實作細節(class、DOM 順序)。 做到這點,前端改版你的測試不會跟著陪葬,這才是自動化真正省時間的地方。