iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
自我挑戰組

Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記系列 第 8

Day 8 - 進階 Locator:Role-based、Chaining、Filtering

  • 分享至 

  • xImage
  •  

Day 5 我們認識了 Playwright 的幾種基本定位方法(getByRolegetByTextgetByLabelgetByTestId),也在 dashboard 頁面上實際比較過用法。Day 7 緩衝日拿商品列表跑了一次完整的 CRUD 流程,因為畫面上要找的元素大多是獨一無二的,所以情境還算單純。

但後台系統裡很常碰到畫面上相同的元素物件不只一個,像是一長串結構相同的資料列——例如訂單列表、商品列表、客戶列表等。今天我們要把 Locator 學得更深入一點,學習如何在一群結構相似的元素中,精準地鎖定我們需要的目標。

為什麼需要更進階的定位技巧

以 Orders 列表為例:畫面上一次顯示 25 筆訂單,每一列都包含日期、訂單編號、顧客姓名、金額等,結構完全相同。如果僅使用 Day 5 教的方法,可能會遇到以下狀況:

  • getByRole('cell', { name: '$120.00' }) 如果剛好有兩筆訂單金額相同,就會違反 strict mode 而報錯。
  • 想點擊某一列的「顧客姓名」欄位,但 getByText('Smith') 可能會連帶抓到地址欄位裡也包含 'Smith' 的文字,導致定位不明確。
  • 想確認「這一整列」的資料時,如果逐一使用 getByText 抓取每個欄位,會讓測試腳本變得冗長且難以維護。

今天要介紹的三個技巧——role-based 的進階用法chainingfiltering——正是為了解決這類「畫面上有多個相似結構的元素,但只想要特定那一個」的情境。

Role-based Locator 再進化:不只使用 name

在前面我們使用 getByRole(role, { name }) 透過角色與名稱來定位元素,但其實 getByRole 還能接受更多篩選條件,這些條件對應到網頁中 ARIA 的各種狀態(state):

選項 對應狀態 範例
checked 是否勾選 getByRole('checkbox', { checked: true })
selected 是否選中 getByRole('tab', { selected: true })
expanded 是否展開 getByRole('button', { expanded: true })
pressed 是否按下(toggle button) getByRole('button', { pressed: true })
disabled 是否被禁用 getByRole('button', { disabled: true })

這些選項可以搭配 name 一起使用,也能單獨使用。單獨使用時特別方便,因為它不是靠這個元素的名稱來定位,而是以畫面上元素目前的狀態來定位,這通常比寫死元素名稱還要穩定得多。

舉例來說,Orders 列表上方有三個分頁籤(ordered / delivered / cancelled),與其去猜測目前選中的分頁名稱,不如直接告訴 Playwright「找出目前處於選取狀態的 tab」:

const activeTab = page.getByRole('tab', { selected: true });

這行程式碼讓我們無論使用者切換到哪一個分頁,都能準確鎖定目前作用中的 tab,不需要在測試腳本裡費心維護「現在是選在哪個分頁」的狀態。

Chaining Locator:先框出範圍,再往下找

Chaining 的概念非常直覺:先用一個 locator 框出指定的範圍,再從該範圍內繼續往下尋找目標元素。寫法上就是在一個 locator 後面直接串接下一個 locator:

const row = page.getByRole('row', { name: /ORD-1234/ });
const totalCell = row.getByRole('cell').last();

在這段程式碼中,第一行先框出「內容符合 ORD-1234 的資料列」,第二行則指定「在這列資料中,尋找最後一個 cell」。由於第二個 locator 是基於第一個元素的範圍出發,就算頁面上有其他資料列也包含 cell,也不會發生定位錯誤,因為搜尋範圍已經限縮在那一列中了。

Chaining 是將定位拆解成幾個清晰的小步驟,不僅易於理解與維護,若在測試中某個層級找不到元素,錯誤訊息也能明確告訴你是哪一層出了問題。

Filtering Locator:從一群符合條件的元素中挑出符合條件的那一個

如果說 chaining 是「先框出範圍再往下尋找」,那麼 .filter() 的策略就是先抓取一整批符合基本條件的元素,再從中篩選出真正需要的目標。其中最常用的是 hasText

const rows = page.getByRole('row');
const targetRow = rows.filter({ hasText: 'ORD-1234' });

這裡的 rows 會先抓取頁面上所有的列(包含表頭),而 .filter({ hasText: 'ORD-1234' }) 則是從這群元素中,篩選出「文字內容包含 ORD-1234」的那一列。hasText 預設採用子字串比對,也可以傳入正則表達式進行更精準的匹配。

另外,.filter() 還有一個進階屬性 has,它的篩選條件不再是文字,而是「這個元素內部是否包含符合另一個 locator 的子元素」:

rows.filter({ has: page.getByRole('cell', { name: '已取消', exact: true }) });

實務上 hasText 已經能應付大部分情境,而 has 則適合用於「條件本身也需要精準定位」的複雜情況。

Chaining 與 filtering 常常會搭配使用:先利用 filtering 從一群結構相同的元素中挑出目標範圍,再透過 chaining 從該範圍內鎖定更細節的子元素。接下來會示範這個組合技巧。

實作演練:在 Orders 列表精準鎖定一筆資料

我們沿用之前建立好的 LoginPage,登入後直接導到 Orders 頁面:

// playwright-tests/tests/orders-locator.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.beforeEach(async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('demo', 'demo');

  await page.getByRole('menuitem', { name: 'Orders' }).click();
});

實作一:role-based,確認目前停在哪個分頁

test('用 selected 狀態確認目前作用中的分頁', async ({ page }) => {
  const activeTab = page.getByRole('tab', { selected: true });

  // 分頁籤上除了 ordered 這個字,後面還接了一個訂單數量,
  // 例如「ordered (12)」,所以這裡故意用正則做部分比對
  await expect(activeTab).toHaveText(/ordered/);
});

💡 小提醒:Orders 頁面的分頁籤標題不是單純的 ordered,而是「ordered」加上即時計算出來的訂單數量,例如 ordered (12)。如果一開始寫 getByRole('tab', { name: 'ordered', exact: true }),會因為畫面上顯示的完整文字對不起來而抓不到元素。改用 { selected: true } 直接問狀態,就不用管數量字串長什麼樣子。

實作二:chaining + filtering,鎖定特定那一筆訂單

由於訂單資料是在專案啟動時隨機產生的(透過 data-generator-retail產生),我們無法事先預知畫面上會出現哪個訂單編號。因此,我們在測試中先從畫面上「撈」出一筆真實存在的資料,再示範如何用這個內容重新鎖定該筆訂單:

test('chaining + filtering:在多筆訂單中鎖定特定那一列', async ({ page }) => {
  const rows = page.getByRole('row');

  // 先從第二列(第一列是表頭)拿到一個真實存在的 reference
  const sampleReference = await rows.nth(1).getByRole('cell').nth(2).innerText();

  // filtering:從所有列裡,篩出「內容包含這個 reference」的那一列
  const targetRow = rows.filter({ hasText: sampleReference });
  await expect(targetRow).toHaveCount(1);

  // chaining:鎖定這一列的範圍後,再往下找這一列裡的金額欄位
  const totalCell = targetRow.getByRole('cell').last();
  await expect(totalCell).toHaveText(/^\$[\d,.]+/);

  // 點擊這一列,確認會正確導向這筆訂單的詳情頁
  await targetRow.click();

  // 先確認真的有跳轉(避免點擊沒反應、還停在列表頁)
  await expect(page).not.toHaveURL(/#\/orders$/);

  // 確認跳轉後顯示的就是該reference的資料——這才是這次目標的斷言
  await expect(page.getByText(sampleReference, { exact: true })).toBeVisible();
});

💡 小提醒.nth(1) 對應到的欄位順序(例如第幾欄是 reference)取決於本地端實際渲染出的表格結構。如果不確定順序,可以打開 DevTools 的 Accessibility 面板,或是利用 codegen 的 Pick locator 點擊確認會是最準確的。

執行測試後,應該會看到這兩筆測試都順利通過。

這幾種定位方式,什麼時候用哪一種

我們簡單整理一下到目前學到的定位策略適合應用在哪個情境:

  • 自己維護的專案,且原始碼可修改:優先使用 data-testid(如同 Day 5 說明的優勢)。
  • 無法修改原始碼的網站(例如測試外部服務時):必須仰賴 role-based 搭配 chaining 與 filtering。
  • 即使是自己的專案並有加上 data-testid,若遇到「結構重複的清單列,卻沒有為每一列加上獨立 testID」的狀況:同樣需要透過 chaining + filtering 才能精準鎖定特定資料。

常見踩雷紀錄

1. 忘記 filter,直接對一堆符合條件的元素操作

在剛開始撰寫腳本時,很容易會遺漏 filtering 步驟,直接對取得的整批 rows 執行 .click()

await page.getByRole('row').click(); // 💥

由於 getByRole('row') 一次會抓到畫面上所有的列,此時就會觸發 Playwright 的 strict mode 錯誤:

Error: strict mode violation: getByRole('row') resolved to 26 elements

這個錯誤防護機制會強迫我們把定位條件寫得更明確,避免測試意外點擊到不正確的目標。

2. hasText 用字串時,不小心篩到不只一筆

hasText 預設採用子字串且不分大小寫的比對方式。如果篩選字串剛好出現在其他欄位中(例如日期或地址裡包含了同樣的數字片段),就會篩選出超過一筆的結果,導致 toHaveCount(1) 的斷言失敗。在這種情況下,改用結合邊界條件的正則表達式(例如 new RegExp(^${reference}$)),會比單純的字串比對更為安全穩固。

3. getByRole 的 name 比對,容易忘記元素上還有其他文字

如同今天 tab 分頁籤的範例所示,網頁上實際渲染出來的文字有時會比我們預期的還要複雜(可能包含數量、單位,甚至是圖示的 aria-label)。如果直接寫死完整字串,很容易會導致定位失敗。建議可以養成一個好習慣:先使用正則表達式進行部分比對,確認測試能順利執行後,若需要更嚴謹的驗證,再考慮是否加上 exact: true 或是改為比對更完整的字串。

4. 用網址格式驗證「有沒有跳到對的頁面」,其實驗證錯東西了

在點擊資料列後,我們可能會很直覺地寫下 expect(page).toHaveURL(/#\/orders\/\d+$/) 進行斷言。這乍看之下很合理,但深入思考後會發現,這只驗證了「網址的格式符合訂單詳情頁」,並沒有實際確認「顯示的內容真的是我剛剛點擊的那一筆」。只要路由格式正確,就算系統跳轉到了別人的訂單,或者路由邏輯出現 bug,這個斷言依然會順利通過。更麻煩的是,若未來路由規則改版(例如從 hash routing 換成 path routing),這段寫死的正則表達式就會直接報錯。

比較穩健的做法是直接驗證畫面內容:我們在前面的步驟中已經透過 sampleReference 取得真實的資料,跳轉後就應該直接比對詳情頁上顯示的內容是否吻合,而不是僅僅驗證網址格式。如果仍想確認「是否有發生頁面跳轉」,可以使用 not.toHaveURL(...) 進行初步檢查,但真正核心的斷言,始終應該聚焦在畫面實際呈現的資料上。


今日小結

今天我們把 Locator 往下學得更深入了一些:

  • Role-based locator 除了 name,還能利用 checkedselectedexpanded 等狀態進行定位,在許多情境下這比猜測文字內容更為穩定。
  • Chaining 的概念是先框出範圍、再往內尋找,將複雜的定位邏輯拆解成易懂的小步驟。
  • Filtering 則是從一群符合條件的元素中,利用 hasTexthas 篩選出特定的目標。
  • 我們也在 Orders 列表的實作演練中,展示了如何搭配使用 chaining 與 filtering,精準鎖定「多筆結構相同的資料列」中的某一筆。

思考一下

今天在實作 filtering 範例時,我們其實一直依賴著 Playwright 內建的自動等待機制。當下若篩選不到或抓不到元素,Playwright 會在背景幫我們不斷重試,直到一定時間後才會報錯,而不是一抓不到就馬上拋出失敗。

但「自動重試到底會等多久」、「等到什麼程度才算逾時」這些行為背後的規則,我們到目前為止還沒有正式探討過。如果你在 Day 5 或是 Day 7 的練習中,已經碰過那個經典的 Test timeout of 30000ms exceeded 錯誤,那麼下一篇文章我們就要正式來搞清楚:Playwright 測試腳本的組織方式(包含 describetest、以及 hooks),並為大家解惑那個30秒的超時標準是怎麼設定。


上一篇
Day 7 - 緩衝日①:後台 CRUD 流程實戰演練
下一篇
Day 9 - Test Runner 基礎:describe / test / hooks 與 timeout 設定
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言