今天要處理下拉選單展開的選項、跳出來的對話框,這些元素有個共同點:它們的 DOM 不是放在觸發它的那顆按鈕底下,而是被 React 的 Portal 丟到 <body> 的最後面。這件事會帶出三個 Playwright 定位機制:
getByRole 找的是 accessibility tree,不是 DOM;且不同操作方法對「有沒有被擋住」的檢查標準也不一樣
role="dialog"
今天會用 react-admin demo 的 Orders(訂單)列表頁來練習。這個頁面篩選工具列上有一顆 Add filter 按鈕,點下去會展開一個下拉選單,列出還沒套用的篩選條件(例如 Returned)——這是今天的下拉選單案例。選單最下面還有一個 Save current query...,點下去會跳出一個標準 MUI Dialog,讓你輸入查詢名稱後存下目前的篩選條件——這是今天的彈跳視窗案例。
今天的程式碼都接在之前做的 loggedInPage fixture 上,因此不需要重複寫登入功能。
先做一個小實驗:把 Orders 篩選的下拉選單點開,打開瀏覽器開發者工具的 Elements 分頁找一下那個展開的選項清單(<ul>)。你會發現它沒有長在你剛剛點的那個欄位裡面,而是被塞到整個網頁 HTML 的最後面,跟觸發它的欄位完全不在同一塊。
這是很多網頁元件庫的特殊設計:浮層常常要蓋在其他東西上面、甚至超出原本容器的邊界(想像一個很窄的欄位裡要開出一個下拉選單,選項清單得超出那個欄位才顯示得下),如果選項清單待在觸發它的欄位裡面,很容易被父層的邊界、捲軸卡住,所以元件庫乾脆把浮層的 HTML 直接搬到頁面最外層。這種做法只有外部元件庫的下拉選單會這樣設計,瀏覽器原生的 <select> 不會有這個問題(原生的可以直接用 Playwright 的 selectOption())。
對寫測試來說,不用搞懂這個技巧的實作細節,只要記住一個結論:畫面上看起來「長在」某個欄位旁邊,不代表它在 HTML 結構裡也是那個欄位的子元素。
import { test, expect } from '../fixtures/fixtures';
test('下拉選單:篩選 Returned = Yes', async ({ loggedInPage: page }) => {
await page.getByRole('menuitem', { name: 'Orders' }).click();
await page.getByRole('button', { name: 'Add filter' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Returned' }).click();
const returned = page.getByRole('combobox', { name: 'Returned' });
await returned.click();
await page.getByRole('option', { name: 'Yes' }).click();
await expect(returned).toHaveText('Yes');
});
這就是為什麼找 option 用的是 page.getByRole('option', ...),不是 returned.getByRole('option')。因為 returned(也就是那個 combobox)底下沒有 option。
如果把上面的 combobox 改成用 page.getByLabel('Returned') 抓,選單展開時會直接報錯:
strict mode violation: getByLabel('Returned') resolved to 2 elements:
1) <div role="combobox" aria-labelledby=":r1k:-label :r1k:">Yes</div>
2) <ul role="listbox" aria-labelledby=":r1k:-label">…</ul>
意思是畫面上同時有兩個東西都能對應到「Returned」這個 label。為什麼會這樣?因為選單展開的時候,畫面上其實同時存在兩個掛著同一個 label 的元素:一個是你點的那個欄位本身(combobox),另一個是剛展開的選項清單(listbox),這兩個元素都掛著同一個 Returned 的 label,所以用label 找,選單展開時就一定會同時撈到兩個。
而且這個問題還有時間性:選單收合之後, listbox 不會馬上從 HTML 移除,在收合動畫還沒跑完前,還會在畫面上多留 200~300 毫秒,這段時間 getByLabel 一樣會抓到兩個。機器快的時候測試可能剛好躲過這段時間而通過,機器慢的時候就會碰剛剛的狀況,而這就是典型的 flaky 測試。
如果我們改用 getByRole('combobox', { name: 'Returned' }) 就沒問題,因為 role 本身已經指定「我要的是 combobox 這個角色」,不會抓到同樣掛著這個 label、但角色其實是 listbox 的東西。
針對對話框的測試基本寫法是把 dialog 抓成一個 locator,之後的操作都掛在它底下,關閉後加上expect(dialog).toBeHidden(),確保對話框有關閉。
test('對話框:存下目前的查詢條件', async ({ loggedInPage: page }) => {
await page.getByRole('menuitem', { name: 'Orders' }).click();
await page.getByRole('button', { name: 'Add filter' }).click();
await page.getByRole('menuitem', { name: 'Save current query...' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Query name').fill('待出貨訂單');
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
});
這段程式碼很單純,但這邊有個問題值得注意:對話框打開的時候,本來畫面上的背景元素,是否能被定位法找到,跟找到之後能不能被操作,其實是兩回事,下面詳細解釋一下:
getByRole 是透過「accessibility tree」來找元素,這是瀏覽器提供給輔助工具使用,像螢幕報讀器這類輔助工具用的清單。MUI Dialog 打開時,會把背景整塊 <div id="root"> 標上 aria-hidden="true",主要是告訴輔助工具「這塊先跳過,使用者現在應該只需要處理對話框」。這個標記同時也會讓 accessibility tree 把整塊背景排除掉,所以用 getByRole 抓取元素的時候,只會在對話框裡面搜尋:
test('modal 打開時,getByRole 只看得到浮層', async ({ loggedInPage: page }) => {
await page.getByRole('menuitem', { name: 'Orders' }).click();
await page.getByRole('button', { name: 'Add filter' }).click();
await page.getByRole('menuitem', { name: 'Save current query...' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('textbox')).toHaveCount(1); // 只剩 Query name
await expect(page.getByRole('button')).toHaveCount(2); // 只剩 Cancel / Save
// 不看 accessibility tree 的定位法,背景全都還在
expect(await page.locator('input').count()).toBeGreaterThan(10);
});
但如果改用不是靠 accessibility tree 找元素的定位法(像 page.locator('input') 這種直接讀 DOM、或 getByPlaceholder 這種讀 HTML 屬性的方法),背景那些元素本來就還在 DOM 跟HTML裡面,所以還是可以抓得到。
抓取到元素,不代表 Playwright 可以讓你對他做任何的操作。呼叫 .click()、.fill() 這類方法時,Playwright 在動手前都會先做一輪檢查(官方叫 actionability check),確認這個元素現在「是否可以被這樣操作」。但檢查哪些項目,是依你呼叫的方法而定,不是每個方法都一樣:
.click() 會檢查這個元素當下有沒有被別的東西擋在前面(這是 receives events)。被擋住的話,就算元素抓得到,Playwright 也不會真的把游標點下去,會直接報錯。.fill() 不做這項檢查,只要元素存在、看得見、是可以編輯的,它就會直接把值寫進去,不管上面有沒有東西蓋著。我們用背景那個搜尋框驗證一次:
const bgSearch = page.getByPlaceholder('Search', { exact: true });
// fill() 不檢查「有沒有被擋住」,所以真的打得進去
await bgSearch.fill('打到背景去了');
await expect(bgSearch).toHaveValue('打到背景去了');
await expect(page.getByRole('dialog')).toBeVisible(); // 對話框還開著
// click() 會檢查「receives events」,同一個元素換成 click 就被攔下來
await expect(bgSearch.click({ timeout: 2000 })).rejects.toThrow(/intercepts pointer events/);
同一個背景元素,.fill() 會成功;但對話框開著的時候,實際上使用者的滑鼠根本無法點到背景那個輸入框,.fill() 卻繞過了這個限制,驗的其實是使用者做不到的事。.click() 則反過來,Playwright 會把它擋下來,因為他會先驗證使用者是否能點得到,可以才會繼續執行。
這個「操作方法之間檢查標準不一樣」是 Playwright 本身的通用規則,官方 actionability 文件列出了每個方法各自會檢查哪些條件。
當不熟悉前端程式的時候,會直覺認為只要是「浮在畫面上、蓋住東西」的元件,應該都會跟 Dialog 一樣,背景被 aria-hidden,但實際上這還是要直接去看原始程式才能夠確定。
先介紹一下這裡要用的另一個功能:react-admin demo 左側選單裡的 Reviews(評論)頁面,是一個列表,每一列是一筆顧客評論。點任何一列,畫面右邊會滑出一塊編輯面板,可以看到評論細節、修改評論的留言(Comment)內容。從畫面上來看,它跟前一節的 Save current query 對話框非常相似,都是點一下後,畫面上多出一塊可以輸入的區域。
照第二節的邏輯,我們會猜測這塊面板也是被標成 role="dialog"、背景也會被 aria-hidden。在還沒有做任何操作的Reviews 頁面上,畫面只有 1 個 textbox(輸入框角色的元素),就是列表本身的搜尋框。
await page.getByRole('menuitem', { name: 'Reviews' }).click();
await expect(page.getByRole('textbox')).toHaveCount(1); // 只有列表搜尋框
接著點開任一列,等右邊的編輯面板滑出來:
await page.getByRole('row').nth(1).click();
await expect(page.getByRole('heading', { name: 'Review detail' })).toBeVisible();
如果這塊面板的行為跟 Dialog 一樣,接下來應該可以用 page.getByRole('dialog') 找到 1 個對應的元素,textbox 的數量維持 1 個,因為背景那個搜尋框被 aria-hidden 藏起來了。結果卻不是這樣:
await expect(page.getByRole('dialog')).toHaveCount(0); // 沒有 dialog 這個 role
await expect(page.getByRole('textbox')).toHaveCount(2); // 搜尋框 + Comment 有兩個
getByRole('dialog') 找到 0 個,代表這塊面板根本沒有被標注成 dialog ,而 textbox 的數量變成 2 個。主要是因為背景的搜尋框沒有被 aria-hidden 藏起來,跟面板裡新出現的 Comment 欄位一起被 getByRole 算了進去。這代表如果直接寫 page.getByRole('textbox') 想抓 Comment 欄位,會因為同時抓到兩個而報錯。
為什麼同樣是「滑出一塊面板」,行為卻完全不一樣?
原因很單純:這兩塊面板底層用的是不同元件。Save current query 用的是 MUI 的 Dialog,Reviews 編輯面板用的是 MUI 的 Drawer(variant="persistent")。這兩個元件的預設行為本來就不一樣,aria-hidden 背景、鎖定鍵盤焦點這些「modal 該有的行為」是 Dialog 才會處理的,Drawer 的 persistent 沒有這些行為。
怎麼調整元素抓取方式呢?
這塊面板沒有 role="dialog" 可以當 scope,遇到這種情況,改成把 accessible name 寫精確一點就好:
await expect(page.getByRole('textbox', { name: 'Comment' })).toBeVisible();
getByRole('textbox', { name: 'Comment' }) 精準指定了角色跟名字,就算畫面上同時有兩個 textbox,也只會命中 Comment 這一個,不會跟背景的搜尋框搞混。
類似視覺效果,可能是用完全不同的元件實作的,所以在寫測試前還是先搞清楚實際的role會比較好。
page.getByRole(...) 從 page 層直接抓。getByRole 靠 accessibility tree 找元素,aria-hidden 的背景它看不到,但用其他直接透過 DOM 屬性的定位方式就可以抓得到。Playwright 中,不同的執行操作有不同的檢查規則:.click() 前會檢查它有沒有被擋住、擋住就報錯,.fill() 卻不做這項檢查會直接成功。明天我們會來處理iframe 與多分頁的測試。