iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
自我挑戰組

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

Day 5 - Locator 基礎入門:選取元素的策略與實戰

  • 分享至 

  • xImage
  •  

Day 4 我們體驗了 Playwright 內建的 Codegen 錄製工具,看到它能在我們操作畫面的同時,自動生成 page.getByRole(...) 等程式碼,前面有簡單解釋這幾行程式碼在做什麼,但沒有仔細說明什麼是 locator。

這篇我們就來深入了解 Playwright 的核心概念之一:Locator(定位器),另外因為我們使用的目標app是使用 Material-UI,所以會順便介紹遇到 Material-UI 時該如何定位元素,最後我們會在 dashboard 頁面上實際寫兩支測試,分別用不同的方式抓元素,然後說明為什麼官方會推薦使用 data-testid

什麼是 Locator?

在 Playwright 中,Locator 不是你要尋找的「那個元素」,而是一個描述怎麼找到那個元素的物件的方法。你可以把它想成一張「尋人啟事」,上面寫著要找的條件,但還沒有真的去找。

// 這一行不會去頁面上找任何東西,也不會報錯
const signInButton = page.getByRole('button', { name: 'Sign in' });

// 真正去頁面上找,是在你對它做動作或斷言的時候
await signInButton.click();

Playwright 的 Locator 物件具有**惰性求值 (Lazy Evaluation)**的特性,也就是在建立時,Playwright 不會立刻去 DOM 搜尋元素,只有在真正執行操作(如 click())或斷言(如 expect().toBeVisible())時才會觸發搜尋,這個設計具有以下幾個好處:

  1. 嚴格的自動等待機制 (Auto-waiting):呼叫 locator.click()locator.fill() 時,Playwright 才會依照這張尋人啟事去頁面上找元素,找不到就等一下再找,直到元素出現、可見(Visible)、以及可以被點擊(Enabled)為止(預設等 30 秒),所以我們幾乎不需要自己寫 waitForSelectorsleep 去處理元素還沒render好就去尋找的情況。
  2. 不會拿到過期的元素:前端框架重新渲染的時候常常會把舊的 DOM 節點丟掉、換上新的,如果你事先把元素抓在手上,很容易碰到 stale element 的問題。Locator 因為每次都重新找,就沒有這個問題。

此外 Playwright 還有一個特性是嚴格模式 (Strict Mode),若一個 Locator 匹配到多個元素,且你試圖執行單一操作(如 click()),Playwright 會直接拋出錯誤,避免誤點錯元素,這樣可以強迫把定位條件寫清楚,確保測試的穩定性。

Playwright 官方推薦的 Locator 定位策略

Playwright 官方提倡「站在真實使用者的角度」來尋找元素,因此優先推薦語意化(Semantic)的 Locator API。以下是常用的定位方法:

1. page.getByRole(role, options)(最推薦)

依據 HTML5 的 ARIA 語意角色(ARIA role或是label)來定位元素,這是官方最推薦的定位方式。

  • 範例:按鈕 getByRole('button', { name: 'Sign in' })
  • 範例:文字輸入框 getByRole('textbox', { name: 'Username' })
  • 範例:標題 getByRole('heading', { name: 'Welcome' })

2. page.getByLabel(text)

專門用來選取與 <label> 標籤關聯的表單輸入框。

  • 範例:page.getByLabel('Password')

3. page.getByText(text)

透過頁面上顯示的文字內容來搜尋元素(適合尋找段落、提示文字或靜態標籤等非互動性的文字)。

  • 範例:page.getByText('Monthly Revenue')

4. page.getByTestId(id)(防線等級的最穩定選取方式)

透過專門為測試設定的 HTML 屬性(預設為 data-testid)來選取。當 UI 文字經常隨語系或需求變更時,這個做法是最不會受 UI 改版影響的策略。

  • 範例:page.getByTestId('welcome-card')

其他備用 LocatorgetByPlaceholder()getByAltText()getByTitle(),以及常見的 locator('css-selector')locator('xpath')。官方建議儘量少用長串的 CSS/XPath來進行定位,因為只要 HTML 結構稍有調整,測試就容易壞掉。

方法 依據 適合的情境
getByRole(role, { name }) ARIA role + 無障礙名稱 按鈕、連結、輸入框、標題,最推薦的語意化寫法
getByLabel(text) 表單的 label <label> 綁定的表單欄位
getByPlaceholder(text) placeholder 屬性 沒有 label、只有提示文字的欄位
getByText(text) 元素的文字內容 非互動性的文字,例如提示訊息
getByAltText(text) 圖片的 alt 圖片
getByTitle(text) title 屬性 有 tooltip 的元素
getByTestId(id) data-testid 屬性 上面幾種都不好用、或想要一個穩定的定位點時

這裡有兩個小細節提醒 :

  • getByRole 的 name 比對預設是不分大小寫、而且會忽略前後空白。所以 getByRole('button', { name: 'sign in' }){ name: 'Sign in' } 效果一樣,如果要求完全一致要加上 exact: true

  • getByText 的字串比對預設是「不分大小寫的子字串比對」。這代表 getByText('sign') 也會抓到 Sign in。同樣要加 exact: true 才會變成完整比對而且區分大小寫。

要寫測試的時候,怎麼知道 role 是什麼

準備要開始寫測試了,但有一個問題是要怎麼知道想要抓的元素的role是什麼呢?這邊提供幾個方法,可以看狀況選擇使用:
第一種最簡單,你只要根據看到的 UI 元素來判斷就好。例如:

你看到的東西 role
按鈕 button
連結 link
文字輸入框 textbox
數字輸入框 spinbutton
勾選框、開關 checkbox
下拉選單 combobox(展開後的每個選項是 option
標題文字 heading
分頁籤 tab
側邊選單項目 menuitem

name 就是元素上顯示的文字,或是它旁邊那個標籤的文字。所以畫面上有一顆寫著 Sign in 的按鈕,直接寫 getByRole('button', { name: 'Sign in' })。如果寫錯了也沒關係,當你把測試跑起來之後,Playwright 會明確告訴你「找不到元素」或是「找到兩個」,這時候看到錯誤訊息再回頭調整就好。

第二種是猜不到的時候,開瀏覽器的開發者工具看。 在 Chrome DevTools 的 Elements 面板選中元素,右側欄有一個 Accessibility 分頁,裡面的 Computed Properties 會直接列出瀏覽器算出來的 Name 跟 Role。不用另外裝任何工具或是先寫好測試,直接在瀏覽器上確認就好。

第三種方法是如果前兩種都還不確定的話,就用 Codegen 的 Pick locator。 Day 4 我們用 Codegen 錄過一段操作,其實它還可以當成元素檢查器用:

npx playwright codegen http://localhost:8000/

https://ithelp.ithome.com.tw/upload/images/20260916/20184177voTMQc5C7b.png

跳出來的 Playwright Inspector 上方有一顆 Pick locator(如圖),點下去之後滑鼠移到哪個元素上,Inspector 就即時告訴你該怎麼抓它。下方的 Locator 分頁會給你一段可以直接複製貼上的 Playwright 語法,旁邊的 Aria 分頁則會顯示這個元素的無障礙結構。

比起前面兩個方法,Codegen 的好處是它直接給你可以貼進測試的完整寫法,而不是只告訴你 role 叫什麼。缺點是要另外開一個視窗。

處理 Material-UI (MUI) 元件的定位眉角

我們的練習網站 react-admin demo 是採用 Material-UI (MUI) 框架開發的。MUI 的元件雖然美觀且功能豐富,但很多元件的實際 DOM 結構跟你看到的東西不太一樣——看起來是 A、實際上是 B,所以這邊先簡單說一下用這個網站寫 E2E 測試常會遇到的幾個「眉角」需要注意:

  1. 結構層層包裹 (DOM Nesting)
    一個簡單的 MUI 按鈕或輸入框,外層可能包了 3 到 5 層 divspan。如果用傳統的 CSS 結構定位(例如 div > div > input),極易脆化(Flaky)。
  2. MUI 下拉選單 (Select) 的 Role
    MUI 的 Select 不是原生的 HTML <select>,點擊後選單選項(Option)會以彈窗(Popover)型態被渲染在 <body> 的最外層。選取時建議先點擊下拉選單觸發按鈕,再用 page.getByRole('option', { name: '選單項目' }) 選取選項。
  3. 動態編譯的 Class Name 不要用
    MUI 產出的 class 名稱通常長這樣:MuiButton-root css-1jy569b-MuiFormLabel-root。後面的 css-1jy569b 是動態編譯生成的 hash,版本升級或元件重新編譯後就會改變,千萬不要直接拿來當 CSS Selector

所以為了更方便後續示範測試程式碼,我們後續的程式會以Codegen抓出來的locator為主。

實作演練:Dashboard 頁面功能測試與 testid 改造

現在我們開啟 demo 後台(預設連線為 http://localhost:8000/)。今天我們要實作兩個功能測試:

  1. 傳統選取方式:使用 getByRolegetByText 測試 Dashboard 頁面。
  2. Test ID 選取方式:親手修改目標 App 的 React 原始碼,加入 data-testid 後,再撰寫對應的 testID 測試腳本!

實作一:用語意化 locator 測 Monthly Revenue 卡片

playwright-tests/tests/ 資料夾下新建 dashboard.spec.ts 檔案,並寫下第一個測試案例:

// playwright-tests/tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Dashboard 頁面元素定位測試', () => {

  test.beforeEach(async ({ page }) => {
    // 1. 登入系統進入 Dashboard
    await page.goto('http://localhost:8000/');
    await page.getByRole('textbox', { name: 'Username' }).fill('demo');
    await page.getByRole('textbox', { name: 'Password' }).fill('demo');
    await page.getByRole('button', { name: 'Sign in' }).click();
  });

  test('使用 getByRole 與 getByText 驗證 Dashboard 卡片與標題', async ({ page }) => {
    // 驗證歡迎標題 (Role)
    const welcomeHeader = page.getByRole('heading', {
      name: 'Welcome to the react-admin e-commerce demo',
    });
    await expect(welcomeHeader).toBeVisible();

    // 驗證指標卡片標題 (Text)
    const monthlyRevenueLabel = page.getByText('Monthly Revenue');
    await expect(monthlyRevenueLabel).toBeVisible();
  });

});

登入之後的 dashboard 上有兩張數字卡片,左邊是 Monthly Revenue、右邊是 New Orders,兩張都可以點,點下去會導到訂單列表。

用剛剛講的 Codegen Pick locator 點一下這張卡片,Inspector 的 Locator 分頁會顯示他產生的 locator:

getByRole('link', { name: 'Monthly Revenue $' })

可以看到整張卡片其實是一個 link,它的無障礙名稱是裡面的標題跟金額單位符號。

下面是今天要寫的測試程式碼:

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.goto('http://localhost:8000/');
  await page.getByRole('textbox', { name: 'Username' }).fill('demo');
  await page.getByRole('textbox', { name: 'Password' }).fill('demo');
  await page.getByRole('button', { name: 'Sign in' }).click();
});

test('Monthly Revenue 卡片顯示金額,點擊後導向訂單列表', async ({ page }) => {
  const revenueCard = page.getByRole('link', { name: /Monthly Revenue/ });

  await expect(revenueCard).toBeVisible();
  await expect(revenueCard).toContainText(/\p{Sc}[\d,]+/u);

  await revenueCard.click();
  await expect(page).toHaveURL(/#\/orders/);
});

beforeEach 是 Playwright Test 的 hook,每支測試跑之前都會先執行一次,我們把之前寫的登入的邏輯從本來的測試搬到這裡統一處理,這樣我們不需要在這個檔案中的每個測試開頭都寫一次登入的邏輯,讓程式碼更簡潔。

這裡有兩個地方特別要注意。

第一,name 用的是正規表達式 /Monthly Revenue/ 而不是完整字串。因為這張卡片的名稱包含金額跟單位符號,如果寫 { name: 'Monthly Revenue $' },未來當單位符號變動,這個 locator 就會壞掉。用正規表達式做部分比對,測試就只綁定在「標題」這個穩定的部分上。

第二,斷言金額用的是格式而不是數值toContainText(/\p{Sc}[\d,]+/u) 驗證的是「有顯示一個幣值單位開頭的數字」。我們使用了正規表達式中的 Unicode 屬性 \p{Sc}(Currency Symbol)並搭配 u flag,這樣就能匹配任何幣別符號(如 $、€、£ 等),而不必限制在特定的錢字號,而金額除非是一個固定值,不然不建議寫死在測試程式中。

實作二:用 data-testid 測 New Orders 卡片

第二支測試改成用 getByTestId。react-admin demo 的原始碼裡面本來沒有任何 data-testid,所以我們得自己加上去。

dashboard 的兩張數字卡片共用同一個元件 demo/src/dashboard/CardWithIcon.tsx,我們在它的 props 加一個 testId,把它掛到最外層的 Card 上,順便也給裡面顯示數值的那個 Typography 一個衍生的 test id:

// demo/src/dashboard/CardWithIcon.tsx
interface Props {
    icon: FC<any>;
    to: To;
    title?: string;
    subtitle?: ReactNode;
    children?: ReactNode;
    testId?: string;          // 新增
}

const CardWithIcon = ({
    icon,
    title,
    subtitle,
    to,
    children,
    testId,                   // 新增
}: Props) => (
    <Card
        data-testid={testId}  // 新增
        sx={{ /* ...原本的樣式不動... */ }}
    >
        {/* ...中略... */}
        <Typography
            variant="h5"
            component="h2"
            data-testid={testId ? `${testId}-value` : undefined}  // 新增
        >
            {subtitle || ' '}
        </Typography>
        {/* ...中略... */}
    </Card>
);

然後在兩個使用它的地方把 testId 傳進去:

// demo/src/dashboard/MonthlyRevenue.tsx
<CardWithIcon
    to="/orders"
    icon={DollarIcon}
    title={translate('pos.dashboard.monthly_revenue')}
    subtitle={value}
    testId="monthly-revenue"
/>

// demo/src/dashboard/NbNewOrders.tsx
<CardWithIcon
    to="/orders"
    icon={ShoppingCartIcon}
    title={translate('pos.dashboard.new_orders')}
    subtitle={value}
    testId="new-orders"
/>

testId 設成 optional,所以其他還沒改的地方(例如 Pending Reviews、New Customers 那兩張卡)不會受影響。

改完之後 vite 會自動熱更新,測試就可以這樣寫:

test('New Orders 卡片顯示本月新訂單數', async ({ page }) => {
  const newOrdersCard = page.getByTestId('new-orders');

  await expect(newOrdersCard).toBeVisible();
  await expect(page.getByTestId('new-orders-value')).toHaveText(/^\d+$/);
});

順帶一提,getByTestId 預設抓的屬性名稱就是 data-testid。如果你的專案已經有自己的慣例(像是 data-testdata-cy),可以在 playwright.config.ts 裡改:

export default defineConfig({
  use: {
    testIdAttribute: 'data-test',
  },
});

現在執行 npx playwright test dashboard.spec.ts --headed,就會看到兩筆測試都順利通過!

為什麼極力推薦使用 data-testid

在實際團隊開發中,雖然 getByRolegetByText 很接近真實使用者的閱讀習慣,但我們強烈推薦在重要元件上補上 data-testid。原因如下:

  1. 測試與 UI 樣式/文案/結構解耦
    如果 PM 決定把登入按鈕文案從 Sign in 改成 Log in,或者產品支援多國語系切換(英文變繁體中文),或是卡片設計成不可點擊(單純顯示資訊),基於 getByTextgetByRole 的測試腳本會立刻報錯壞掉。但如果採用 data-testid,不管文案怎麼變,測試依然穩如泰山。
  2. 解決 MUI 複雜結構的痛點
    在 MUI 等 UI 庫中,很多自訂卡片或區塊缺乏明顯的 HTML5 語意角色(Role)。直接在 React 元件外層加上 data-testid="monthly-revenue-card",測試工程師就能一秒定位,不需再去研究 MUI 的 nested div 結構。
  3. 促進前端開發者與 QA 的合作規範
    data-testid 作為元件合約(Contract)的一部分,前端工程師在重構 UI 或替換樣式庫時,只要保留 data-testid,就能確保自動化測試完全不受影響。

今日小結

今天我們學習了 Playwright 的 Locator 機制:

  • 認識了 Playwright 的自動等待與嚴格模式。
  • 掌握了 Playwright 內建的幾種定位方法(getByRolegetByTextgetByLabelgetByTestId)。
  • 了解 Material-UI 元件常見的幾個定位陷阱。
  • 在 dashboard 上寫了兩支測試,分別用 getByRolegetByTestId 定位,也動手在 demo 的原始碼加了第一個 data-testid

下一篇我們會來學習 Playwright 自動化測試最經典的架構——Page Object Model (POM)


上一篇
Day4 - Codegen 初體驗: 用錄的也能寫測試?
下一篇
Day 6 - POM 基礎:把登入包裝成 LoginPage,重構 Day 2 的測試
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言