iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
自我挑戰組

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

Day 10 - Assertions 與 expect API:這些斷言方法你都用對了嗎?

  • 分享至 

  • xImage
  •  

前面的幾天我們討論了如何抓取畫面元素、怎麼把測試包成一組,但還沒好好介紹expect有哪些用法、什麼時候該用哪一種,還有什麼時候必須要加上 await。

什麼是斷言?為什麼測試需要它

斷言(assertion)就是測試用來判定「通過」或「失敗」的依據。如果一支測試裡面沒有任何斷言,那不管畫面跑成什麼樣子,測試最後都會是通過,因為根本沒有任何一行程式碼在檢查結果到底對不對。

其實我們在前面幾天的測試裡,已經默默用過不少 expect 了,今天就把它的用法系統化地整理一次,順便補上幾個之前還沒介紹過的好用方法。

兩種 expect:一般斷言 vs web-first assertion

在 Playwright 裡面,expect 其實包含了兩種完全不同運作方式的斷言。決定的關鍵在於你傳入 expect() 的是什麼東西。

  • 傳入 expect() 的是一個 Locator(或是 Page、APIResponse):Playwright 會知道「這是畫面上的東西,它的狀態可能還在變化」。這類的 matcher(像是 toBeVisible、toHaveText、toHaveValue 等)在底層是 async function。它會不斷地重新檢查這個 locator 目前的狀態,直到條件成立,或者是等到 expect.timeout超時為止。這種會自動重試的斷言,我們通常稱之為 web-first assertion。
  • 傳入 expect() 的是一個你手上已經拿到的純值(字串、數字、布林值等):這個時候,Playwright 手上沒有畫面元素可以重新查詢,它只能檢查你當下給的這個值對不對。這類的 matcher(像是 toBe、toEqual、toContain 等)是同步函式。它執行完會立刻回傳結果,只檢查一次,不會有重試的機制,一般我們稱為一般斷言(generic assertion)。

我們用 Dashboard 頁面上的「本月新訂單數」來對照一下(new-orders-value 這個 testid 是我們在 Day 5 幫 CardWithIcon 元件加上去的):

// dashboard.spec.ts
// 一般斷言:先用 await 把「當下那一刻」的文字內容拿出來
// textContent() 本身回傳 Promise<string>,所以要先 await 拿到字串
const text = await page.getByTestId('new-orders-value').textContent();
// 到這裡 text 已經是一個普通字串,跟畫面沒有關係了
// expect(text).toBe() 只檢查這個字串「現在」對不對,檢查一次就結束,不會等待
expect(text).toBe('5');
// 如果這行執行的當下,畫面資料還沒跑出來,text 可能還是空字串,就會直接判定失敗

寫 toBe('5') 語法上沒錯,它就是一般斷言,但如果直接拿來跑在這個 demo project 上,很容易就會失敗,因為新訂單數是跟著資料變動的,我們不能保證它剛好就是 5。同樣是一般斷言,這種情況我們可以改用 toMatch() 搭配正規表達式,只檢查格式對不對,不去綁死特定的數字:

expect(text).toMatch(/^\d+$/);

那如果是 web-first assertion 呢?

// web-first assertion:把 locator「本身」直接交給 expect(),不要先 await 出文字
// toHaveText() 內部會反覆重新查詢這個元素目前的文字,
// 直到變成 '5',或等到 expect.timeout 超時為止,才會 resolve 或 reject
await expect(page.getByTestId('new-orders-value')).toHaveText('5');

await 不代表會「觸發重試」

這邊大家很容易會有一個誤解:以為是加了 await,Playwright 才「開始」去重試。但實際上,只要我們呼叫了一個 async function(不管前面有沒有寫 await),重試邏輯就會立刻啟動;await 真正的作用,只是讓呼叫它的那一行暫停下來等結果,這完全是獨立的兩件事。

所以,如果忘記加 await,雖然背景的重試機制依然會被啟動,但會帶來兩個嚴重的隱患:

  1. 控制流失效(Race Condition):測試不會停在這一行等待,而是會立刻衝去執行下一行。如果下一行操作依賴這個斷言檢查的狀態(例如:斷言彈窗出現後才點擊按鈕),下一行就會因為狀態未就緒而直接失敗。
  2. 除錯訊息與時機錯亂:如果該斷言最終失敗(Timeout Rejection),因為當下沒有被 await 捕捉,這個錯誤可能會在後面其他無關的步驟、甚至是測試已經結束時才爆發出來,變成難以追查的 Unhandled Rejection,讓測試變得極度不可靠(Flaky)。

只要記住一個簡單的規則:expect() 裡面放的是 Locator、Page 或 APIResponse,就一定要加上 await。

判斷要不要加 await 的簡單原則:

情境 範例 要不要 await
expect() 裡放的是 Locator / Page / APIResponse expect(locator).toBeVisible() 一定要,它是 async function
expect() 裡放的是你手上已經拿到的純值 expect(text).toBe('5') 不用,它是同步函式

常用 matcher 整理

大部分情況下,我們不會用一般斷言去驗證畫面上的東西(前面的示範只是為了對照原理)。實務上,幾乎都是直接對 Locator 使用 web-first assertion。這邊幫大家整理一下前面幾天已經用過、還有這次要補充的常用 matcher:

分類 Matcher 用途
元素可見性/狀態 toBeVisible() / toBeHidden() / toBeEnabled() / toBeDisabled() 檢查元素在不在畫面上、能不能操作
文字內容 toHaveText() / toContainText() toHaveText 是完全比對,toContainText 是部分比對
表單值 toHaveValue() 檢查 input 目前的值
數量 toHaveCount() 檢查符合條件的元素有幾個
頁面層級 toHaveURL() / toHaveTitle() 檢查網址、分頁標題
一般值(非 locator) toBe() / toEqual() / toContain() 驗證你手上已經算好的純值

回顧一下,在 dashboard.spec.ts 跟 orders-locator.spec.ts 裡面,其實已經用過不少了:

// dashboard.spec.ts
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/);
// orders-locator.spec.ts
const activeTab = page.getByRole('tab', { selected: true });
await expect(activeTab).toHaveText(/ordered/);

const targetRow = rows.filter({ hasText: sampleReference });
await expect(targetRow).toHaveCount(1);

const totalCell = targetRow.getByRole('cell').last();
await expect(totalCell).toHaveText(/^\$[\d,.]+/);

這裡再補一個目前還沒示範過的 toHaveValue(),我們用 ProductCreatePage 的 widthInput 舉個例子——填完表單後,順手確認欄位裡的值真的有被填進去:

// crud-product.spec.ts
await productCreatePage.widthInput.fill('10');
await expect(productCreatePage.widthInput).toHaveValue('10');

用 .not 做反向斷言

當我們想驗證「某個狀態不應該發生」的時候,可以在 matcher 前面加上 .not。我們在 orders-locator.spec.ts 裡面就有一個現成的例子:點擊某一列訂單之後,先確認網址真的有跳轉、不是還停留在列表頁:

await targetRow.click();

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

.not 也一樣支援自動重試,它的邏輯是「反覆檢查,直到條件不成立、或者是等到 timeout 為止」,用法跟一般的 web-first assertion 完全一致,只是判斷的方向反過來。

Soft Assertions:讓斷言失敗後測試繼續跑

前面用到的 expect 都有一個共同性:只要斷言失敗,該測試就會立刻中止,後面的程式碼全都不會執行。這在大多數情況下是很合理的行為,但有時候我們會想要「一次檢查好幾個彼此獨立的項目」,就算其中一個失敗了,也希望繼續看到其他項目的結果,而不是碰到第一個錯就馬上結束。

這時候就可以使用 expect.soft()。例如,我們想一次檢查 Dashboard 上好幾張卡片是不是都正常顯示:

// dashboard.spec.ts
test('一次檢查 Dashboard 上所有卡片都正常顯示', async ({ page }) => {
  await expect.soft(page.getByRole('link', { name: /Monthly Revenue/ })).toBeVisible();
  await expect.soft(page.getByTestId('new-orders')).toBeVisible();
  await expect.soft(page.getByTestId('pending-reviews')).toBeVisible();  // 沒有設定pending-reviews 的testId,所以這個斷言會失敗
  // 就算其中一個斷言失敗,其他還是會繼續執行,
  // 等整支測試跑完,才一次列出所有失敗的項目
});

這種寫法很適合「多個斷言彼此獨立,但想一次看到全部測試結果」的情況,像是上面這種檢查好幾張卡片是否都有顯示的例子就很適合。

不過如果後面的步驟強烈依賴前面的斷言成立(例如要先確認登入成功,才有辦法繼續操作後台),那就不該用 soft,用了反而會讓測試在一個不合理的情境下繼續跑下去。

自訂錯誤訊息

當斷言失敗時,Playwright 預設的錯誤訊息其實已經包含了 matcher 名稱、locator 內容跟等待紀錄。不過有時候如果想要讓失敗訊息更容易理解,可以加上自訂的說明:

await expect(productsPage.resultCount, '搜尋後應該要有結果數字顯示').toBeVisible();

傳入第二個參數是自訂訊息,斷言失敗時這段文字就會跟著預設的錯誤訊息一起顯示出來。

https://ithelp.ithome.com.tw/upload/images/20260920/20184177w6WLgasVtC.png

今日小結

今天我們介紹了 expect 相關的用法,重點包含:

  • 兩種斷言的差別:一般斷言(同步、不會重試)與 web-first assertion(非同步、會自動重試)。
  • await 的必要性:傳入 Locator 給 expect 時,務必加上 await。
  • 好用的進階功能:包含反向斷言 .not、讓測試繼續跑的 expect.soft(),以及自訂錯誤訊息。

下一篇我們會介紹 Fixture,來幫忙解決現在每次測試前面都必須手動呼叫 loginPage.login(...) 登入的問題!


上一篇
Day 9 - Test Runner 基礎:describe / test / hooks 與 timeout 設定
下一篇
Day 11 - Fixture 基礎:為什麼我們需要它?
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言