iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
自我挑戰組

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

Day 25 - 網路攔截與 Mock API:讓測試決定 API 回什麼

  • 分享至 

  • xImage
  •  

上一篇設計參數化的資料表時提到,預期值要選穩定的資料:分類和品名是寫死在資料產生器裡的,所以可以放心斷言「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。
  • 請求沒有真的送到伺服器,頁面拿到的就是這兩筆資料。

https://ithelp.ithome.com.tw/upload/images/20261005/20184177mIHVzg6323.png

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);       // 確認原本真的有資料
});

流程是:

  1. route.fetch() 把請求真的送到伺服器,拿到真實的回應。
  2. 把回應轉成 JSON,在陣列最後加上一筆 Loquat。
  3. route.fulfill({ response, json }) 把修改後的資料交給頁面。傳入 response 表示沿用真實回應的狀態碼和 header,json 則覆蓋掉原本的 body。

最後一行的 realCount 是用來確認「真實資料確實有東西」,不然如果 API 本來就回傳空陣列,畫面只剩 Loquat 一筆,這個測試就跟範例一沒有差別了。

https://ithelp.ithome.com.tw/upload/images/20261005/201841776Cr409yZqE.png

兩個範例比較:

fulfill(範例一) fetch + fulfill(範例二)
會不會真的打 API 不會 會
資料是否可預期 完全可預期 只有你改的那部分可預期
後端沒啟動時 照樣能跑 會失敗
適合 固定資料、後端還沒做好 測試特定資料(例如超長名稱)混在真實資料裡的表現

範例三:模擬錯誤情境

最後是開場提到、平常最難重現的錯誤情境。這裡分成兩種:

  • 伺服器回 500:伺服器有收到請求、也有回應,只是回應說「我出錯了」。
  • 網路中斷:請求根本沒有得到任何回應。
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') });
});
  • 500 用的還是 fulfill,只是多傳了 status: 500。因為有回應,所以用 waitForResponse 等到回應後檢查狀態碼。
  • 網路中斷用 abort(),參數是錯誤代碼(internetdisconnected、failed、timedout 等,完整清單見官方文件)。因為沒有回應,waitForResponse 等不到東西,要改用 requestfailed 事件。

errorText 的內容每個瀏覽器不一樣,這裡的 INTERNET_DISCONNECTED 是 Chromium 的格式,所以這兩支測試只用 --project=chromium 執行。

為什麼這兩支沒有寫成參數化?

上一篇剛學完參數化,這兩個錯誤情境看起來很適合寫成兩筆資料跑同一支測試。但實際寫下去會發現,兩者的斷言方式不一樣:500 有回應,要檢查狀態碼;abort 沒有回應,要監聽 requestfailed。

參數化適合「步驟和斷言都一樣,只有資料不同」的情況。如果要為了參數化在測試裡寫 if 判斷是哪一種錯誤,反而會讓測試更難讀,不如直接寫成兩支。

意外發現:這個頁面沒有處理錯誤

原本想斷言「畫面出現錯誤訊息」,所以先截圖看看頁面在錯誤時長什麼樣子,結果兩種情況都沒有任何提示:

API 回 500:整個頁面一片空白,連標題都不見了。

https://ithelp.ithome.com.tw/upload/images/20261005/2018417776DoM7hwsa.png

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

https://ithelp.ithome.com.tw/upload/images/20261005/20184177a6L6cd4h9p.png

這個頁面是官方用來示範 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 的情境:

  • 需要固定資料才能寫出明確的斷言
  • 錯誤情境(500、逾時、斷網)
  • 後端 API 還沒做好,前端要先開發測試
  • 特殊資料:空清單、超長文字、大量資料

要注意的風險: mock 的資料是你自己寫的,如果後端之後改了回傳格式(例如欄位名稱從 name 改成 title),mock 不會跟著改,測試也不會失敗,所以務必記得跟著調整測試。

所以比較好的做法是:大部分測試用 mock 控制資料和錯誤情境,但至少保留一條打真實後端的主要流程,確保前後端真的串得起來。

小結

今天學會用 page.route() 在請求送出前攔下來,並用三種方式處理:

  • route.fulfill():直接回假資料,讓資料完全可預期
  • route.fetch() + fulfill():拿到真實回應後只改一部分
  • fulfill({ status: 500 }) / route.abort():模擬伺服器錯誤和網路中斷

資料能控制之後,測試就穩定多了。下一篇我們會用 Global Setup 搭配 storageState,讓登入只做一次。


上一篇
Day 24 - 參數化測試:一份資料表,產生多支測試
下一篇
Day 26 - Global Setup + storageState:讓登入只要做一次的方法
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言