上一篇設計參數化的資料表時提到,預期值要選穩定的資料:分類和品名是寫死在資料產生器裡的,所以可以放心斷言「Animals 分類一定有 Cat Nose」;但庫存、價格每次載入都是隨機產生的,只能避開不測。
可是實務上,價格顯示對不對、庫存為 0 時畫面長怎樣,往往正是需要測的地方。如果資料不固定,就只能選擇不測,或寫出時好時壞的測試。
另一個常見的狀況是錯誤畫面:API 回 500 時畫面會顯示什麼?網路斷掉時會不會一直轉圈?這些情境對於使用者體驗也很重要。
這兩個問題都可以利用今天要介紹的網路攔截(Network Interception)來解決,它能讓我們決定 API 要回什麼來幫助進行測試。
這個系列一直用 react-admin demo 當主線,但它的資料其實不是從真正的後端來的:demo 使用 FakeRest 在瀏覽器裡模擬 API,請求在瀏覽器內就被接住並回應了,沒有真的送出請求到網路上。
今天要學的攔截是在「請求送出去之前」把它攔下來,而 demo 的請求根本沒有送出去,所以沒有東西可以攔。實務上,前端通常是打真正的後端 API,所以這篇我們借用 Playwright 官方的 Mock API 練習頁:
https://demo.playwright.dev/api-mocking
這個頁面很單純:打開後會呼叫 /api/v1/fruits 這支 API,再把回傳的水果清單顯示在畫面上。官方文件的 Mock API 教學也是用這個頁面,想看更多寫法可以對照著看。
page.route:在請求送出前攔截Playwright 攔截請求用的是 page.route(),它接收兩個參數:
await page.route('要攔截的網址', async route => {
// 決定這個請求要怎麼處理
});
第一個參數是網址的比對規則,可以是 glob 字串、正規表示式或函式;第二個參數是處理函式,每當頁面發出符合規則的請求,Playwright 就會把它攔下來交給這個函式,由你決定接下來怎麼處理。
route 物件常用的處理方式有四種:
| 方法 | 做了什麼 | 適合情境 |
|---|---|---|
route.fulfill() |
不送出請求,直接回傳你準備好的回應 | 完全使用假資料 |
route.fetch() |
真的送出請求並拿到回應,你可以修改後再回傳 | 保留真實資料,只改一部分 |
route.abort() |
讓請求直接失敗,不會有任何回應 | 模擬網路中斷 |
route.continue() |
照常送出(可以順便改 header 等內容) | 只想觀察或微調請求 |
今天會實作前三種,continue() 的用法可以參考官方文件。
有一個規則要先記住:page.route() 要在請求發出之前註冊。這個頁面一打開就會呼叫 API,所以 route 一定要寫在 page.goto() 前面,寫在後面的話請求早就送出去了,攔不到任何東西。
route.fulfill 完全換成假資料不管真正的 API 會回什麼,一律回傳我們準備好的兩筆水果。
import { test, expect } from '@playwright/test';
const DEMO_URL = 'https://demo.playwright.dev/api-mocking';
const FRUITS_API = '*/**/api/v1/fruits';
test('fulfill:畫面只顯示 mock 的水果', async ({ page }) => {
// route 要在請求發出前註冊,所以寫在 goto 之前
await page.route(FRUITS_API, async route => {
await route.fulfill({
json: [
{ name: 'Strawberry', id: 21 },
{ name: 'Mock Mango', id: 22 },
],
});
});
await page.goto(DEMO_URL);
await expect(page.getByText('Strawberry')).toBeVisible();
await expect(page.getByText('Mock Mango')).toBeVisible();
});
幾個重點:
'*/**/api/v1/fruits' 是 glob 寫法,意思是「不管前面的網域和路徑是什麼,只要結尾是 /api/v1/fruits 就攔下來」。fulfill 的 json 參數會自動把物件轉成 JSON 字串,並幫你設定 Content-Type: application/json。
Mock Mango 是我自己編的水果名稱,真實 API 不可能回傳這筆資料,所以只要它出現在畫面上,就能證明頁面顯示的是我們的假資料,而不是真實資料剛好也有同名的水果。
回到開頭的問題:資料由測試決定之後,不管是價格、庫存還是任何原本會變動的欄位,預期值都可以直接寫死,不用再為了穩定而避開不測。
route.fetch 拿真實回應再修改有時候我們不想整份造假,只想在真實資料上動一點手腳,例如多塞一筆資料,看看畫面能不能正常顯示。這時候用 route.fetch():
test('fetch:保留真實資料並多加一筆', async ({ page }) => {
let realCount = 0;
await page.route(FRUITS_API, async route => {
const response = await route.fetch(); // 真的打一次 API
const json = await response.json();
realCount = json.length;
json.push({ name: 'Loquat', id: 100 });
await route.fulfill({ response, json }); // 沿用真實回應的 status / headers,只換 body
});
await page.goto(DEMO_URL);
await expect(page.getByText('Loquat', { exact: true })).toBeVisible();
expect(realCount).toBeGreaterThan(0); // 確認原本真的有資料
});
流程是:
route.fetch() 把請求真的送到伺服器,拿到真實的回應。Loquat。route.fulfill({ response, json }) 把修改後的資料交給頁面。傳入 response 表示沿用真實回應的狀態碼和 header,json 則覆蓋掉原本的 body。最後一行的 realCount 是用來確認「真實資料確實有東西」,不然如果 API 本來就回傳空陣列,畫面只剩 Loquat 一筆,這個測試就跟範例一沒有差別了。

fulfill(範例一) |
fetch + fulfill(範例二) |
|
|---|---|---|
| 會不會真的打 API | 不會 | 會 |
| 資料是否可預期 | 完全可預期 | 只有你改的那部分可預期 |
| 後端沒啟動時 | 照樣能跑 | 會失敗 |
| 適合 | 固定資料、後端還沒做好 | 測試特定資料(例如超長名稱)混在真實資料裡的表現 |
最後是開場提到、平常最難重現的錯誤情境。這裡分成兩種:
test('錯誤情境:API 回 500', async ({ page }) => {
await page.route(FRUITS_API, route =>
route.fulfill({ status: 500, json: { message: 'Mock server error' } }),
);
const responsePromise = page.waitForResponse(res => res.url().includes('/api/v1/fruits'));
await page.goto(DEMO_URL);
const response = await responsePromise;
expect(response.status()).toBe(500);
await page.screenshot({ path: test.info().outputPath('api-500.png') });
});
test('錯誤情境:網路中斷(abort)', async ({ page }) => {
await page.route(FRUITS_API, route => route.abort('internetdisconnected'));
const failedPromise = page.waitForEvent('requestfailed', req => req.url().includes('/api/v1/fruits'));
await page.goto(DEMO_URL);
const failed = await failedPromise;
expect(failed.failure()?.errorText).toContain('INTERNET_DISCONNECTED');
await page.screenshot({ path: test.info().outputPath('api-abort.png') });
});
fulfill,只是多傳了 status: 500。因為有回應,所以用 waitForResponse 等到回應後檢查狀態碼。abort(),參數是錯誤代碼(internetdisconnected、failed、timedout 等,完整清單見官方文件)。因為沒有回應,waitForResponse 等不到東西,要改用 requestfailed 事件。
errorText的內容每個瀏覽器不一樣,這裡的INTERNET_DISCONNECTED是 Chromium 的格式,所以這兩支測試只用--project=chromium執行。
上一篇剛學完參數化,這兩個錯誤情境看起來很適合寫成兩筆資料跑同一支測試。但實際寫下去會發現,兩者的斷言方式不一樣:500 有回應,要檢查狀態碼;abort 沒有回應,要監聽 requestfailed。
參數化適合「步驟和斷言都一樣,只有資料不同」的情況。如果要為了參數化在測試裡寫 if 判斷是哪一種錯誤,反而會讓測試更難讀,不如直接寫成兩支。
原本想斷言「畫面出現錯誤訊息」,所以先截圖看看頁面在錯誤時長什麼樣子,結果兩種情況都沒有任何提示:
API 回 500:整個頁面一片空白,連標題都不見了。

網路中斷:畫面停在 Loading...,不會結束也沒有任何提示。

這個頁面是官方用來示範 mock 的,本來就沒有做錯誤處理,畫面上沒有東西可以斷言,所以這兩支測試只能驗證錯誤確實被模擬出來了。
如果是你自己的專案,而且前端有做錯誤處理,測試就能直接斷言使用者看到的畫面。例如(示意,錯誤訊息請依你專案的實際內容調整):
await page.route('**/api/v1/fruits', route => route.fulfill({ status: 500 }));
await page.goto('/fruits');
await expect(page.getByText('資料載入失敗,請稍後再試')).toBeVisible();
反過來說,如果你在自己的專案裡用 mock 跑出像上面那樣的白畫面或永遠的 Loading,那就是發現了一個bug。沒有 mock 的話,這種問題通常要等到正式環境的後端真的出錯,才會被使用者發現。
mock 很好用,但不是越多越好。
適合 mock 的情境:
要注意的風險: mock 的資料是你自己寫的,如果後端之後改了回傳格式(例如欄位名稱從 name 改成 title),mock 不會跟著改,測試也不會失敗,所以務必記得跟著調整測試。
所以比較好的做法是:大部分測試用 mock 控制資料和錯誤情境,但至少保留一條打真實後端的主要流程,確保前後端真的串得起來。
今天學會用 page.route() 在請求送出前攔下來,並用三種方式處理:
route.fulfill():直接回假資料,讓資料完全可預期route.fetch() + fulfill():拿到真實回應後只改一部分fulfill({ status: 500 }) / route.abort():模擬伺服器錯誤和網路中斷資料能控制之後,測試就穩定多了。下一篇我們會用 Global Setup 搭配 storageState,讓登入只做一次。