前面的幾天我們討論了如何抓取畫面元素、怎麼把測試包成一組,但還沒好好介紹expect有哪些用法、什麼時候該用哪一種,還有什麼時候必須要加上 await。
斷言(assertion)就是測試用來判定「通過」或「失敗」的依據。如果一支測試裡面沒有任何斷言,那不管畫面跑成什麼樣子,測試最後都會是通過,因為根本沒有任何一行程式碼在檢查結果到底對不對。
其實我們在前面幾天的測試裡,已經默默用過不少 expect 了,今天就把它的用法系統化地整理一次,順便補上幾個之前還沒介紹過的好用方法。
在 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,Playwright 才「開始」去重試。但實際上,只要我們呼叫了一個 async function(不管前面有沒有寫 await),重試邏輯就會立刻啟動;await 真正的作用,只是讓呼叫它的那一行暫停下來等結果,這完全是獨立的兩件事。
所以,如果忘記加 await,雖然背景的重試機制依然會被啟動,但會帶來兩個嚴重的隱患:
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') |
不用,它是同步函式 |
大部分情況下,我們不會用一般斷言去驗證畫面上的東西(前面的示範只是為了對照原理)。實務上,幾乎都是直接對 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 完全一致,只是判斷的方向反過來。
前面用到的 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();
傳入第二個參數是自訂訊息,斷言失敗時這段文字就會跟著預設的錯誤訊息一起顯示出來。

今天我們介紹了 expect 相關的用法,重點包含:
await 的必要性:傳入 Locator 給 expect 時,務必加上 await。.not、讓測試繼續跑的 expect.soft(),以及自訂錯誤訊息。下一篇我們會介紹 Fixture,來幫忙解決現在每次測試前面都必須手動呼叫 loginPage.login(...) 登入的問題!