iT邦幫忙

2026 iThome 鐵人賽

DAY 18
1

如果你以為 Playwright 只能開瀏覽器點來點去,這篇會打開新世界:它也能直接呼叫 API。更重要的是兩者的混搭——用 API 佈景、用 UI 驗戲。這是讓測試速度翻倍、穩定度翻倍的關鍵一手。

這篇跟前面幾篇不一樣:不靠官方練習站,我們自己動手建一個迷你受測系統。跟 Claude Code 說一句話就能生出來,之後所有練習都打這個系統。你們公司系統有 API 的話,套路直接搬過去用。

API 是什麼,用你熟的話說

你在畫面上按「送出訂單」,瀏覽器背後其實是對伺服器發了一個請求:「幫我建一筆訂單,內容如下」。那個請求走的通道,就是 API。

UI 是給人看的皮,API 是系統之間講話的管道。同一件事,可以請人透過畫面做,也可以直接對管道說。
https://ithelp.ithome.com.tw/upload/images/20260817/20161809k6l7djaSIo.png
圖 1:建一筆訂單,走 UI 與走 API 的成本對比

走 UI 建一筆訂單:開瀏覽器、登入、搜尋、加購物車、填資料、送出,十五秒起跳,每一步都可能出狀況。走 API:一個請求,一秒內,幾乎不會不穩。這個成本差,就是接下來混搭策略的全部理由。

動手前,三個名詞先白話一次

• 端點(endpoint):API 的門牌號碼,像 /api/orders。對這個門牌發請求,就是在跟訂單服務講話。
• 請求與回應(request / response):你送出去的叫請求,系統回來的叫回應。測 API 就是「發一個請求,驗證回應長得對」。
• 狀態碼:回應上的三位數字,系統的表情符號。200 是成功,404 是找不到,500 是系統自己出錯了。看到 4 開頭先檢查自己的請求,5 開頭是對方的問題——這條判讀規則,除錯時天天用。
就這三個。其他細節出現時再問 AI。

Playwright 測得到哪些 API,測不到哪些

先把邊界畫清楚,免得拿著鎚子看什麼都像釘子。判斷標準只有一條:這個 API 是不是走一般的 HTTP 請求/回應。是,Playwright 的 request 物件就打得到;走長連線,只能從瀏覽器端旁觀或攔截;走非 HTTP 的專屬協定,就換工具。
https://ithelp.ithome.com.tw/upload/images/20260817/20161809B2sXu5QaMl.png
圖 2:Playwright 測 API 的能力範圍

https://ithelp.ithome.com.tw/upload/images/20260817/20161809QYRt1O9SqW.png
表 1:各類 API 與 Playwright 的對應。

實務上這條界線很少造成困擾:你在瀏覽器 DevTools 的 Network 面板看得到的東西,幾乎都測得到,而那正是 UI 自動化最需要搭配的部分。gRPC 內部服務、訊息佇列這些瀏覽器根本碰不到的東西,本來就該由後端測試或專門工具負責,硬塞給 Playwright 是工具選錯,誰來做都做不好。表中「間接」那兩列不是紙上談兵,練習五、六會親手做一次。

混搭:API 備料,UI 驗菜

https://ithelp.ithome.com.tw/upload/images/20260817/20161809ZuaaUvI3IF.png
圖 3:準備與清理走 API,重點驗證走 UI

假設要測訂單列表的分頁,列表得先有二十幾筆訂單才分得了頁。天真的做法是用 UI 下二十次單,光準備就五分鐘,還沒開始測分頁。

混搭的做法:用 API 快速塞好二十五筆訂單(佈景),開瀏覽器測列表顯示與分頁(這才是戲),測完用 API 清掉資料(收尾)。

原則一句話:測試的重點走 UI(那是使用者體驗所在),前置與收尾走 API(那只是佈景,快穩優先)。哪段是重點、哪段是佈景,是測試設計的判斷,不是技術判斷。

業界常見的四種手法,一次盤點

把 API 和 UI 混著用,業界玩了十幾年,常見套路就四種。前面談的佈景清理是第一種,另外三種也值得認識,免得只會一招。

https://ithelp.ithome.com.tw/upload/images/20260817/20161809BWe1LIZDMc.png
圖 4:業界常見的四種 API × UI 混用手法

• 手法一:API 佈景與清理。測試資料的準備和收尾走 API,把分鐘級的前置壓到秒級。(練習二)
• 手法二:API 登入 + storageState。登入走 API 一次,把登入狀態存成檔案,所有 UI 測試帶著已登入身分開場。每條測試省 5 到 10 秒,還少掉登入頁這個常見的不穩來源。 (練習三)
• 手法三:純 API 驗證。不開瀏覽器,直接發請求驗回應。適合業務規則、錯誤處理、權限這類跟畫面無關的檢查,毫秒級回饋,適合鋪大量案例。(練習一)
• 手法四:UI 操作 + API 驗後端。方向反過來:在畫面上做動作,再用 API 查後端資料是否真的正確。專抓「畫面說成功,資料庫沒存進去」這種 UI 與後端不同步的缺陷。(練習四)

四種之外,還有兩招住在瀏覽器這一側:旁觀(waitForResponse,看 UI 背後發出的請求與回應長什麼樣)和攔截(page.route,把後端回應換掉,測平常製造不出來的失敗情境)。嚴格說它們不是在「測 API」,而是把 API 當觀測點和控制點,但實務上跟前四招常一起出現,練習五、六會各做一次。至於契約測試(contract testing,像 Pact)驗的是服務之間的介面約定,有專門工具,不在 Playwright 的守備範圍,知道名字、知道不歸這裡管就夠了。

純 API 測試:三類值得測

• UI 到不了的規則:同一帳號同時下兩筆單會怎樣?API 模擬得出來,UI 很難。
• 錯誤處理:直接發格式錯誤的請求,驗證系統回正確的錯誤,而不是噴 500。前端會擋這些輸入,但繞過前端直接打 API 的人不會客氣——這是資安視角的測試。
• 回應內容:欄位齊不齊、格式對不對。列表頁顯示錯誤的案子,有一半根因是 API 就給錯了,API 層測試能把問題定位在源頭。

要開始寫,把 API 文件丟給 Claude Code 就行。讀文件、寫請求、驗回應,這整套它做得比多數人快。你的價值在挑場景,上面三類是起點。

剎車:別讓 API 測試取代 UI 測試

API 快又穩,用上癮之後會有股衝動,把什麼都改成 API 測。停一下。

使用者活在 UI 上。按鈕沒反應、訊息沒出現、跑版——API 層全部看不到。兩層各司其職:API 層驗規則和資料,UI 層驗體驗和整合。

實作練習:先建受測程式,再寫測試

這次不用官方練習站,我們自己養一隻受測系統,然後六個練習一對一走完:四種業界手法各一個,瀏覽器側的旁觀與攔截各一個。程式碼都附上,但建議先自己對 Claude Code 下指令,寫出來再跟參考解對照——差異的地方,往往就是你學到東西的地方。
https://ithelp.ithome.com.tw/upload/images/20260817/20161809Iqlc6RKJdt.png
表 2:練習與手法的一對一對應。

步驟 0:環境準備

mkdir api-lab && cd api-lab
npm init -y
npm install express
npm install -D @playwright/test
npx playwright install chromium

步驟 1:生成受測程式
你可以這樣對 Claude Code 說:

幫我寫一個練習用的迷你訂單服務 server.js,用 Express,資料放記憶體就好:
1. POST /api/login:帳密 qa / pw123,成功回傳 token 並設 auth cookie,錯誤回 401
2. 授權:API 接受 Bearer token 或 auth cookie 兩種方式
3. POST /api/orders:建訂單,缺 item 或 amount 不是正數要回 400
4. GET /api/orders?page=&size=:分頁查詢,新到舊排序,回傳 total、totalPages、data
5. DELETE /api/orders/:id:刪除,不存在回 404
6. GET /login:登入頁,登入成功導向 /orders,失敗顯示錯誤訊息
7. GET /orders:訂單列表頁,沒登入導回 /login;要有新增訂單表單(品項、金額、
   成功/失敗訊息)、每頁 10 筆的分頁按鈕與頁碼資訊、API 失敗時顯示
   「訂單載入失敗,請稍後再試」
跑在 port 3000。

讀程式碼之前先看地圖。整個系統就三層:上層兩個瀏覽器頁面、中層四支 API、下層一個記憶體陣列。右側兩個灰框是關鍵——同一套 API 認兩種憑證,API 測試用 Bearer token、瀏覽器用 cookie,後面六個練習都建立在這個雙軌設計上。

https://ithelp.ithome.com.tw/upload/images/20260817/20161809JkrNicfp23.png
圖 5:受測系統的三層結構與雙軌授權,標註了各區塊對應的練習

參考解如下(約 200 行,註解占了不少,慢慢讀)。程式碼裡每一段的註解都標了「這段是哪個練習要驗的行為」,之後寫測試卡住時,回來這裡找對應段落,通常就知道測試該驗什麼、為什麼過不了。

// =============================================================
// server.js — 練習用受測程式 v2:迷你訂單服務
// =============================================================
// 啟動:node server.js(跑在 http://localhost:3000)
//
// 整個系統就三層,對照文件裡的架構圖:
//   [瀏覽器頁面]  /login 登入頁、/orders 訂單列表頁
//   [API 端點]    /api/login、/api/orders 的增查刪
//   [資料]        一個放在記憶體的陣列,重啟就清空
//
// 每一段行為都是某個練習要驗的對象,註解會標出來。
// =============================================================
const express = require('express');
const app = express();
app.use(express.json());   // 讓 Express 看得懂 JSON 格式的請求內容
 
// ---- 資料層:記憶體陣列,沒有資料庫 ----
// 練習用系統刻意做到最簡:orders 存所有訂單,nextId 給流水號。
// 重啟即清空——這也是為什麼每個測試要自己佈景、自己收尾。
let orders = [];
let nextId = 1;
 
// 寫死一組 token 當「登入憑證」。真實系統會動態簽發,
// 但對練習來說,重點是「有沒有帶憑證」的行為,不是憑證怎麼來。
const TOKEN = 'demo-token-123';
 
// ---- 登入 API ----
// 成功時做兩件事:
//   1. 回傳 token(給 API 測試用,之後放在 Authorization 標頭)
//   2. 設 auth cookie(給瀏覽器用,之後每個請求自動帶上)
// 同一次登入發兩種憑證,就是「雙軌授權」——
// 練習三的 storageState 能成立,靠的就是第 2 件事。
app.post('/api/login', (req, res) => {
  const { username, password } = req.body || {};
  if (username === 'qa' && password === 'pw123') {
    res.setHeader('Set-Cookie', `auth=${TOKEN}; Path=/; HttpOnly`);
    return res.json({ token: TOKEN });
  }
  // 帳密錯:401,而且「不設 cookie」——登入頁的錯誤訊息靠這個觸發
  res.status(401).json({ error: 'invalid credentials' });
});
 
// ---- 授權檢查:兩種憑證擇一即可 ----
// API 測試走 Bearer token(練習一),瀏覽器走 cookie(練習二之後)。
function hasAuth(req) {
  if (req.headers.authorization === `Bearer ${TOKEN}`) return true;   // 軌道一:標頭
  return (req.headers.cookie || '').includes(`auth=${TOKEN}`);        // 軌道二:cookie
}
// Express 的「中介層」寫法:掛在路由上,沒過就擋下,過了才進主邏輯
function auth(req, res, next) {
  if (hasAuth(req)) return next();
  res.status(401).json({ error: 'unauthorized' });   // 練習一第 3 個測試驗這裡
}
 
// ---- 建立訂單:POST /api/orders ----
// 輸入驗證是重點:壞輸入要回 400 和明確錯誤訊息,
// 不能讓程式炸掉噴 500(練習一第 2 個測試驗這裡)。
// 「前端會擋」不算數——繞過前端直接打 API 的人不會客氣。
app.post('/api/orders', auth, (req, res) => {
  const { item, amount } = req.body || {};
  if (typeof item !== 'string' || !item.trim()) {
    return res.status(400).json({ error: 'item is required' });
  }
  if (typeof amount !== 'number' || amount <= 0) {
    return res.status(400).json({ error: 'amount must be a positive number' });
  }
  // 驗證都過了才建資料:回 201(建立成功)和完整的訂單物件,
  // 欄位齊不齊,是練習一第 1 個測試驗的
  const order = { id: nextId++, item, amount, createdAt: new Date().toISOString() };
  orders.push(order);
  res.status(201).json(order);
});
 
// ---- 查詢訂單列表:GET /api/orders?page=1&size=10 ----
// 分頁邏輯全在這裡:新到舊排序、算總頁數、切出該頁的資料。
// 練習二在 UI 上驗的「第 1 / 3 頁(共 25 筆)」,數字源頭就是這段。
app.get('/api/orders', auth, (req, res) => {
  const page = parseInt(req.query.page || '1', 10);   // 沒給就當第 1 頁
  const size = parseInt(req.query.size || '10', 10);  // 沒給就每頁 10 筆
  const sorted = [...orders].sort((a, b) => b.id - a.id);   // 新到舊
  const start = (page - 1) * size;
  res.json({
    total: orders.length,                                   // 總筆數
    page,
    size,
    totalPages: Math.max(1, Math.ceil(orders.length / size)), // 至少 1 頁
    data: sorted.slice(start, start + size),                // 這一頁的資料
  });
});
 
// ---- 刪除訂單:DELETE /api/orders/:id ----
// 成功回 204(做完了,沒東西好回);找不到回 404(練習一第 4 個測試)。
// 各練習「收尾清資料」打的就是這支。
app.delete('/api/orders/:id', auth, (req, res) => {
  const id = parseInt(req.params.id, 10);
  const idx = orders.findIndex(o => o.id === id);
  if (idx === -1) return res.status(404).json({ error: 'order not found' });
  orders.splice(idx, 1);
  res.status(204).end();
});
 
// ---- 登入頁(UI):GET /login ----
// 一個最簡單的表單頁:填帳密、按登入。
// 頁面裡的 script 打的正是上面那支 /api/login——
// UI 是皮,底下走的還是同一條 API,這就是本篇的核心觀念。
app.get('/login', (req, res) => {
  res.send(`<!DOCTYPE html>
<html lang="zh-Hant"><head><meta charset="utf-8"><title>登入</title></head>
<body>
  <h1>登入</h1>
  <label>帳號 <input id="username"></label>
  <label>密碼 <input id="password" type="password"></label>
  <button id="login-btn">登入</button>
  <p id="login-error" style="color:red"></p>
  <script>
    document.getElementById('login-btn').onclick = async () => {
      // 按下登入:把表單內容打向 /api/login
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          username: document.getElementById('username').value,
          password: document.getElementById('password').value,
        }),
      });
      // 成功:瀏覽器已自動收下 Set-Cookie,直接跳轉訂單頁
      if (res.ok) location.href = '/orders';
      // 失敗:顯示錯誤訊息(不跳轉)
      else document.getElementById('login-error').textContent = '帳號或密碼錯誤';
    };
  </script>
</body></html>`);
});
 
// ---- 訂單列表頁(UI):GET /orders ----
// 進頁面前先檢查身分:沒登入(沒 cookie)就轉址回登入頁。
// 練習三驗的「開場即已登入、不被踢回 /login」,判斷點就在這一行。
app.get('/orders', (req, res) => {
  if (!hasAuth(req)) return res.redirect('/login');
  res.send(`<!DOCTYPE html>
<html lang="zh-Hant"><head><meta charset="utf-8"><title>訂單列表</title></head>
<body>
  <h1>訂單列表</h1>
  <!-- 新增訂單表單:練習四在這裡操作 UI,再用 API 驗後端 -->
  <div>
    <input id="new-item" placeholder="品項">
    <input id="new-amount" placeholder="金額" type="number">
    <button id="create-btn">新增訂單</button>
    <span id="create-msg"></span>
  </div>
  <!-- 錯誤訊息區:平常是空的,API 掛掉才顯示(練習六的驗證目標) -->
  <p id="error" style="color:red"></p>
  <ul id="order-list"></ul>
  <!-- 分頁控制:練習二驗按鈕行為,練習五旁觀按鈕觸發的請求 -->
  <div>
    <button id="prev">上一頁</button>
    <span id="page-info"></span>
    <button id="next">下一頁</button>
  </div>
  <script>
    let page = 1;   // 目前在第幾頁,按上一頁/下一頁就加減它
    async function load() {
      // 跟 API 要目前這一頁的資料(cookie 由瀏覽器自動帶上)
      const res = await fetch('/api/orders?page=' + page + '&size=10');
      if (!res.ok) {
        // API 回失敗:顯示友善訊息,而不是白畫面或當掉。
        // 練習六用 page.route 假造 500,驗的就是這個分支——
        // 真實後端很難「壞給你看」,所以這行平常根本跑不到。
        document.getElementById('error').textContent = '訂單載入失敗,請稍後再試';
        return;
      }
      document.getElementById('error').textContent = '';
      const body = await res.json();
      // 把 API 給的資料畫到畫面上:
      // 如果這裡畫錯,就是「API 對、畫面錯」——練習五的旁觀能分辨這件事
      document.getElementById('order-list').innerHTML =
        body.data.map(o => '<li>#' + o.id + ' ' + o.item + ' $' + o.amount + '</li>').join('');
      document.getElementById('page-info').textContent =
        '第 ' + body.page + ' / ' + body.totalPages + ' 頁(共 ' + body.total + ' 筆)';
      // 第一頁不能再往前、最後一頁不能再往後(練習二驗按鈕停用)
      document.getElementById('prev').disabled = page <= 1;
      document.getElementById('next').disabled = page >= body.totalPages;
    }
    document.getElementById('prev').onclick = () => { page--; load(); };
    document.getElementById('next').onclick = () => { page++; load(); };
    // 新增訂單:把表單內容打向 POST /api/orders,成功就回第一頁重載
    document.getElementById('create-btn').onclick = async () => {
      const res = await fetch('/api/orders', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          item: document.getElementById('new-item').value,
          amount: Number(document.getElementById('new-amount').value),
        }),
      });
      const msg = document.getElementById('create-msg');
      if (res.ok) { msg.textContent = '新增成功'; page = 1; load(); }
      else { msg.textContent = '新增失敗:' + (await res.json()).error; }
    };
    load();   // 進頁面先載第一頁
  </script>
</body></html>`);
});
 
app.listen(3000, () => console.log('SUT running at http://localhost:3000'));

建議的閱讀順序,五分鐘走完:

• 先讀資料層(最上面十行):一個陣列、一個流水號,沒了。理解「重啟即清空」,就理解為什麼每個測試要自己佈景收尾。
• 再讀登入與授權(/api/login 和 hasAuth):一次登入發兩種憑證是全篇的軸心。hasAuth 兩行,一行一個軌道。
• 然後是三支訂單 API:每支的重點不是功能,是「壞情況怎麼回」——壞輸入 400、沒憑證 401、找不到 404。練習一驗的全是這些。
• 最後才是兩個頁面:注意頁面 script 裡的 fetch 打的就是上面那幾支 API。UI 是皮,底下走的是同一條管道——親眼在程式碼裡看到這件事,比讀十次定義都有用。

存檔後 node server.js 啟動,瀏覽器開 http://localhost:3000/orders,應該被轉到登入頁;用 qa / pw123 登入後看到空列表,正常。

練習一(手法三):純 API 驗證,不開瀏覽器
對 Claude Code 說:

受測系統跑在 http://localhost:3000,API 規格如 server.js。
請用 Playwright 的 request 物件寫純 API 測試 tests/api-orders.spec.js,涵蓋:
1. 建立訂單成功:回 201,回應要有 id、item、amount、createdAt,測完把資料刪掉
2. 錯誤處理:缺 item 要回 400,不能噴 500
3. 權限:沒帶 token 一律 401
4. 刪除不存在的訂單:回 404
不要開瀏覽器,全部用 request 打。

參考解:

// =============================================================
// tests/api-orders.spec.js — 練習一(手法三):純 API 測試
// =============================================================
// 執行:npx playwright test tests/api-orders.spec.js
//
// 這個檔案從頭到尾不開瀏覽器。關鍵是參數列的 { request }:
// 那是 Playwright 內建的 API 客戶端,會發 HTTP 請求、收回應,
// 跟瀏覽器一點關係都沒有——所以才能 1 秒級跑完。
// =============================================================
const { test, expect } = require('@playwright/test');
 
const BASE = 'http://localhost:3000';
let token;   // 登入拿到的憑證,存起來給每個測試共用
 
// beforeAll:整個檔案開跑前先執行一次。
// 登入這種「每個測試都需要、但本身不是測試重點」的事,放這裡最省。
test.beforeAll(async ({ request }) => {
  const res = await request.post(`${BASE}/api/login`, {
    data: { username: 'qa', password: 'pw123' },   // data 會自動轉成 JSON 送出
  });
  expect(res.status()).toBe(200);   // 登入本身失敗的話,後面全免談,先驗
  token = (await res.json()).token;
});
 
test('建立訂單:回 201,回應欄位齊全', async ({ request }) => {
  // 發請求:POST + 授權標頭 + JSON 內容,三件事一次給齊
  const res = await request.post(`${BASE}/api/orders`, {
    headers: { Authorization: `Bearer ${token}` },
    data: { item: '珍珠奶茶', amount: 60 },
  });
 
  // 驗回應,由粗到細:先狀態碼,再內容
  expect(res.status()).toBe(201);                            // 201 = 建立成功
  const order = await res.json();
  expect(order).toMatchObject({ item: '珍珠奶茶', amount: 60 });  // 存進去的跟送出去的一致
  expect(order.id).toBeGreaterThan(0);                       // 系統有給流水號
  expect(order.createdAt).toBeTruthy();                      // 有建立時間
 
  // 收尾:自己建的資料自己清。順便驗刪除成功是 204
  const del = await request.delete(`${BASE}/api/orders/${order.id}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  expect(del.status()).toBe(204);
});
 
test('錯誤處理:缺 item 要回 400,不能噴 500', async ({ request }) => {
  // 故意送一筆壞資料:沒有 item,金額還是負的
  const res = await request.post(`${BASE}/api/orders`, {
    headers: { Authorization: `Bearer ${token}` },
    data: { amount: -5 },
  });
  // 4 開頭 = 你的請求有問題;5 開頭 = 系統自己掛了。
  // 壞輸入應該得到前者。如果這裡收到 500,代表系統沒做輸入驗證,直接炸了
  expect(res.status()).toBe(400);
  expect((await res.json()).error).toContain('item');   // 錯誤訊息要講清楚缺什麼
});
 
// 未登入情境要特別處理:練習三導入 storageState 之後,
// 連 request 物件都會自帶登入 cookie(專案層級設定,人人有份)。
// 「沒登入」的測試必須明確說:我不要那個存檔,給我乾淨的身分。
test.describe('未登入情境', () => {
  test.use({ storageState: { cookies: [], origins: [] } });   // 清空,拒領登入狀態
 
  test('權限:沒帶 token 一律 401', async ({ request }) => {
    // 這次刻意「不帶」授權標頭,cookie 也被上面清掉了
    const res = await request.get(`${BASE}/api/orders`);
    expect(res.status()).toBe(401);   // 不認識你,拒收
  });
});
 
test('刪除不存在的訂單:回 404', async ({ request }) => {
  const res = await request.delete(`${BASE}/api/orders/99999`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  expect(res.status()).toBe(404);   // 找不到就說找不到,不能假裝成功
});

參考解裡有個地方現在看會困惑:401 測試外面多包了一層 test.describe 和 test.use。那是練習三會親身撞到的坑,先在這裡補好了——現在跳過沒關係,練習三會回來講這個故事。

跑起來的實際輸出(在我機器上驗證過;這是完成練習三、掛上 config 之後的輸出,所以第一行多了 setup):

$ npx playwright test tests/api-orders.spec.js --reporter=list
 
Running 5 tests using 1 worker
 
  ✓  1 [setup] › auth.setup.js › API 登入並儲存 storageState (64ms)
  ✓  2 [chromium] › 建立訂單:回 201,回應欄位齊全 (56ms)
  ✓  3 [chromium] › 錯誤處理:缺 item 要回 400,不能噴 500 (18ms)
  ✓  4 [chromium] › 未登入情境 › 權限:沒帶 token 一律 401 (49ms)
  ✓  5 [chromium] › 刪除不存在的訂單:回 404 (62ms)
 
  5 passed (2.3s)

四個測試加一個 setup,2.3 秒;還沒掛 config 前,四個測試 1.1 秒。同樣的檢查如果走 UI,光登入就不只這個時間。這就是「秒級跑完」的感覺,體驗過就回不去了。

練習二(手法一):API 佈景,UI 驗戲

對 Claude Code 說:

測試訂單列表的分頁(http://localhost:3000/orders,每頁顯示 10 筆):
請先透過 POST /api/orders 建立 25 筆測試訂單,把登入 cookie 塞進瀏覽器 context,
然後開瀏覽器驗證:第一頁 10 筆、頁碼顯示「第 1 / 3 頁(共 25 筆)」、
連按兩次下一頁後第三頁 5 筆、下一頁按鈕停用。
測試結束後把建立的 25 筆訂單全部刪除。

參考解:

// =============================================================
// tests/ui-pagination.spec.js — 練習二(手法一):
// API 佈景 → UI 驗戲 → API 收尾,三段式的完整示範
// =============================================================
// 執行:npx playwright test tests/ui-pagination.spec.js
//
// 注意參數列同時要了三樣東西:
//   page    = 瀏覽器分頁(UI 世界)
//   context = 這個瀏覽器的環境,cookie 放這裡
//   request = API 客戶端(API 世界)
// request 和 page 是兩個獨立的身分——這是本練習最重要的觀念。
// =============================================================
const { test, expect } = require('@playwright/test');
const BASE = 'http://localhost:3000';
 
test('訂單列表分頁:25 筆資料、每頁 10 筆', async ({ page, context, request }) => {
  // ---------- ① 佈景(API):快,不是重點,越快越好 ----------
  const login = await request.post(`${BASE}/api/login`, {
    data: { username: 'qa', password: 'pw123' },
  });
  const token = (await login.json()).token;
  const headers = { Authorization: `Bearer ${token}` };
 
  // 塞 25 筆:26 個請求兩三秒搞定。同樣的事走 UI 要好幾分鐘
  const ids = [];
  for (let i = 1; i <= 25; i++) {
    const res = await request.post(`${BASE}/api/orders`, {
      headers, data: { item: `商品 ${i}`, amount: i * 10 },
    });
    ids.push((await res.json()).id);   // 記下 id,收尾要用
  }
 
  // 搭橋:request 登入拿到的憑證,「不會」自動出現在瀏覽器。
  // 得親手把 cookie 塞進瀏覽器的 context,page 才算登入。
  // (每條測試都這樣搭橋很囉唆?對,所以練習三要教 storageState)
  await context.addCookies([{ name: 'auth', value: token, url: BASE }]);
 
  // ---------- ② 驗戲(UI):慢,但驗的是使用者看到的東西 ----------
  await page.goto(`${BASE}/orders`);
  await expect(page.locator('#order-list li')).toHaveCount(10);   // 第一頁 10 筆
  await expect(page.locator('#page-info')).toContainText('第 1 / 3 頁(共 25 筆)');
  await page.click('#next');
  await page.click('#next');
  await expect(page.locator('#order-list li')).toHaveCount(5);    // 25 筆的第三頁剩 5 筆
  await expect(page.locator('#next')).toBeDisabled();             // 最後一頁,按鈕該停用
 
  // ---------- ③ 收尾(API):把自己建的 25 筆清乾淨 ----------
  // 不清的話,下一條測試看到的就是髒資料,測試之間互相污染
  for (const id of ids) {
    await request.delete(`${BASE}/api/orders/${id}`, { headers });
  }
});

兩個重點。第一,佈景 26 個 API 請求(登入加 25 筆)大約兩三秒,改用 UI 一筆一筆下單,光準備就要好幾分鐘,親手跑一次比讀十篇文章有感。第二,context.addCookies 那行——下面這張圖值得多看一分鐘:

https://ithelp.ithome.com.tw/upload/images/20260817/20161809MZsqg82E1P.png
圖 6:request 和 page 是兩個獨立身分,兩座橋與一個副作用

request 物件和 page 是兩個獨立的身分:request 用 API 登入,拿到的憑證收在它自己口袋裡,瀏覽器完全不知道。所以要親手把 cookie 塞進瀏覽器的 context(圖中的橋一),page 才算登入。每條測試都這樣搬很囉唆——圖中的橋二就是下個練習:存檔一次,全體共用。

練習三(手法二):API 登入 + storageState,全部測試開場即登入

練習二每條測試都自己登入一次、自己塞 cookie,測試一多就重複。業界的標準解法:開一個 setup 專案,API 登入一次、把登入狀態存成檔案,其他測試宣告依賴它,開場就是已登入身分。對 Claude Code 說:

幫我把登入抽成 Playwright 的 setup project:
1. tests/auth.setup.js:用 request 打 POST /api/login,把 storageState 存到
   playwright/.auth/user.json
2. playwright.config.js:setup 專案先跑,chromium 專案設 storageState 並
   dependencies 依賴 setup
3. 寫一個測試 tests/logged-in.spec.js 驗證:直接 goto /orders 不會被轉回登入頁

參考解,三個檔案:

// =============================================================
// tests/auth.setup.js — 練習三(手法二)之一:
// API 登入「一次」,把登入狀態存成檔案
// =============================================================
// 這不是一般測試,是 setup:在所有測試之前先跑的準備步驟。
// { test: setup } 只是改個名字,提醒讀的人這是準備,不是驗證。
const { test: setup } = require('@playwright/test');
 
setup('API 登入並儲存 storageState', async ({ request }) => {
  // 用 API 登入——不開瀏覽器、不填表單,一秒內完成
  const res = await request.post('http://localhost:3000/api/login', {
    data: { username: 'qa', password: 'pw123' },
  });
  if (!res.ok()) throw new Error('登入失敗,檢查受測系統是否啟動');
 
  // 關鍵一行:登入回應裡的 Set-Cookie,request 已經收下了。
  // storageState() 把它連同其他狀態寫成 JSON 檔——
  // 這個檔案就是「已登入身分」的存檔,誰讀它誰就是登入狀態。
  await request.storageState({ path: 'playwright/.auth/user.json' });
});
// =============================================================
// playwright.config.js — 練習三(手法二)之二:
// 讓「先登入存檔」和「帶檔開跑」自動串起來
// =============================================================
module.exports = {
  testDir: './tests',
  projects: [
    // 專案一:setup。只跑 auth.setup.js,負責產生登入狀態檔
    { name: 'setup', testMatch: /auth\.setup\.js/ },
 
    // 專案二:真正的測試。
    //   storageState:每個測試的瀏覽器一開起來,就讀這個檔,
    //                 等於「出生就是登入狀態」
    //   dependencies:宣告要先等 setup 跑完——順序 Playwright 自己排
    {
      name: 'chromium',
      use: {
        browserName: 'chromium',
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
};
// =============================================================
// tests/logged-in.spec.js — 練習三(手法二)之三:
// 驗證「開場即已登入」真的成立
// =============================================================
// 注意這條測試「完全沒有登入動作」:沒填表單、沒 addCookies。
// 登入狀態來自 config 裡的 storageState,測試本身乾乾淨淨。
const { test, expect } = require('@playwright/test');
 
test('開場即已登入:直接進訂單頁,不會被踢回登入頁', async ({ page }) => {
  await page.goto('http://localhost:3000/orders');
 
  // 受測系統的規則:沒登入進 /orders 會被轉址到 /login。
  // 所以「網址還在 /orders」就是登入生效最直接的證據
  await expect(page).toHaveURL(/\/orders/);
  await expect(page.locator('h1')).toHaveText('訂單列表');
});

從此練習二的 addCookies 也可以刪掉。這招每條 UI 測試省 5 到 10 秒,更重要的是少掉登入頁這個常見的不穩來源:登入只在一個地方失敗,不會在三十條測試裡各失敗一次。

然後說那個坑——我掛上這份 config 重跑練習一,紅了一條:「沒帶 token 應 401」拿到 200。想通就不奇怪:storageState 是專案層級設定,人人有份,連 request 物件都自帶登入 cookie 了,受測系統認 cookie 這條軌,當然放行。這正是圖 6 底部那個灰框寫的副作用。修法是讓未登入的測試明確拒領登入狀態:

// 「沒登入」的測試,要明確說:我不要那個存檔,給我乾淨的身分
test.describe('未登入情境', () => {
  test.use({ storageState: { cookies: [], origins: [] } });   // 清空,拒領登入狀態
 
  test('權限:沒帶 token 一律 401', async ({ request }) => {
    const res = await request.get(`${BASE}/api/orders`);
    expect(res.status()).toBe(401);
  });
});

練習一參考解裡那層看不懂的 test.describe,就是這個。這種「全域方便」和「個別例外」的拉扯,在真實專案的登入設計裡天天上演,現在先摔過一次,以後遇到就認得。

練習四(手法四):UI 操作,API 驗後端

方向反過來:畫面上做動作,後端查真相。對 Claude Code 說:

寫一個測試:在 /orders 頁面用表單新增一筆訂單(品項:鐵觀音、金額:80),
驗證畫面顯示「新增成功」之後,不要看畫面,直接用 request 打 GET /api/orders,
驗證那筆訂單真的存在、金額正確。測完刪掉。

參考解:

// =============================================================
// tests/ui-create-order.spec.js — 練習四(手法四):
// UI 操作 + API 驗後端。畫面說成功不算數,後端有存才算數
// =============================================================
const { test, expect } = require('@playwright/test');
const BASE = 'http://localhost:3000';
 
test('UI 新增訂單,用 API 驗證後端真的有存', async ({ page, context, request }) => {
  // 準備:登入 + 搭橋(同練習二;用了練習三的 config 就能省掉這幾行)
  const login = await request.post(`${BASE}/api/login`, {
    data: { username: 'qa', password: 'pw123' },
  });
  const token = (await login.json()).token;
  const headers = { Authorization: `Bearer ${token}` };
  await context.addCookies([{ name: 'auth', value: token, url: BASE }]);
 
  // ---------- 前半:UI 操作,跟真人一樣填表單 ----------
  await page.goto(`${BASE}/orders`);
  await page.fill('#new-item', '鐵觀音');
  await page.fill('#new-amount', '80');
  await page.click('#create-btn');
  await expect(page.locator('#create-msg')).toHaveText('新增成功');
  // 到這裡,一般 UI 測試就收工了。但「畫面說成功」只證明
  // 前端把訊息印出來了,沒證明資料真的進了後端
 
  // ---------- 後半:API 驗後端,查真相 ----------
  // 不看畫面,直接問 API:剛剛那筆在不在?
  const res = await request.get(`${BASE}/api/orders?page=1&size=10`, { headers });
  const created = (await res.json()).data.find(o => o.item === '鐵觀音');
  expect(created).toBeTruthy();          // 真的存在
  expect(created.amount).toBe(80);       // 而且金額沒被存錯
 
  // 收尾
  await request.delete(`${BASE}/api/orders/${created.id}`, { headers });
});

這招專抓一種缺陷:畫面顯示成功,但後端其實沒存進去,或存錯了。只驗畫面的測試對這種問題完全免疫——畫面本來就會說成功。列表頁顯示錯誤的案子有一半根因在 API,反過來,「畫面說對了」也未必代表資料對了,兩邊都查才算數。

練習五(旁觀):看 UI 背後發出的請求

前面都是「自己發請求」,接下來兩個練習換位置:請求由頁面自己發,測試站在線路上。差別在於一個只看、一個換掉,先看圖分清楚:

https://ithelp.ithome.com.tw/upload/images/20260817/20161809UyvqPJXYls.png
圖 7:旁觀時請求照走真實後端;攔截時真實後端根本沒被打到

這個練習做上半部的旁觀:使用者按下一頁時,頁面自己發了什麼請求、拿回什麼回應。對 Claude Code 說:
寫一個測試:先用 API 建 15 筆訂單,進 /orders 頁按「下一頁」,
用 waitForResponse 攔下頁面發出的 page=2 請求,驗證:回應是 200、
body 的 page 是 2、data 有 5 筆,而且畫面上也顯示 5 筆。測完清資料。

參考解:

// =============================================================
// tests/observe.spec.js — 練習五(旁觀):
// 看 UI 背後發出的請求。只看,不動——請求照常走向真實後端
// =============================================================
const { test, expect } = require('@playwright/test');
const BASE = 'http://localhost:3000';
 
test('按下一頁時,旁觀頁面發出的 API 請求', async ({ page, context, request }) => {
  // 佈景:15 筆,剛好兩頁(10 + 5),下一頁按鈕才有戲唱
  const login = await request.post(`${BASE}/api/login`, {
    data: { username: 'qa', password: 'pw123' },
  });
  const token = (await login.json()).token;
  const headers = { Authorization: `Bearer ${token}` };
  const ids = [];
  for (let i = 1; i <= 15; i++) {
    const res = await request.post(`${BASE}/api/orders`, {
      headers, data: { item: `旁觀商品 ${i}`, amount: 10 },
    });
    ids.push((await res.json()).id);
  }
  await context.addCookies([{ name: 'auth', value: token, url: BASE }]);
  await page.goto(`${BASE}/orders`);
 
  // 旁觀的核心寫法:Promise.all 讓「開始等」和「按按鈕」同時發生。
  // 順序很重要——先按再等的話,請求可能已經回來了,等不到。
  // waitForResponse 的條件函式:只攔我關心的那個請求(page=2 那次)
  const [response] = await Promise.all([
    page.waitForResponse(r => r.url().includes('/api/orders') && r.url().includes('page=2')),
    page.click('#next'),
  ]);
 
  // 現在手上同時有兩份證據,可以交叉比對:
  // 證據一:API 給了什麼(線路上的回應)
  expect(response.status()).toBe(200);
  const body = await response.json();
  expect(body.page).toBe(2);
  expect(body.data.length).toBe(5);
  // 證據二:畫面畫了什麼
  await expect(page.locator('#order-list li')).toHaveCount(5);
  // 兩份都對 = 系統沒問題。
  // 若證據一就錯 → 問題在後端;證據一對、證據二錯 → 問題在前端。
  // 這就是旁觀的價值:顯示錯誤時,一次就能定位是誰的鍋
 
  for (const id of ids) await request.delete(`${BASE}/api/orders/${id}`, { headers });
});

這招的價值在除錯定位:列表顯示錯的時候,旁觀一次就知道是 API 給錯(回應就不對,問題在後端)還是畫面畫錯(回應是對的,問題在前端)。同一個原理套在 WebSocket 上就是 page.on('websocket') 聽訊息幀——我們的受測系統沒有 WebSocket,等你的系統真的有,套路一樣。

練習六(攔截):把後端換掉,測失敗路徑

圖 7 的下半部:不旁觀了,直接冒充。真實後端很難「壞給你看」,但 page.route 可以在請求離開瀏覽器前就攔下,回一個假的 500——失敗情境要多少有多少,真實後端連請求都沒收到。對 Claude Code 說:

寫一個測試:用 page.route 攔截所有打向 /api/orders 的請求,一律回 500,
然後進 /orders 頁,驗證畫面顯示「訂單載入失敗,請稍後再試」,
而不是白畫面或當掉。

參考解:

// =============================================================
// tests/mock.spec.js — 練習六(攔截):
// 把後端回應整個換掉。請求「不會」到達真實後端
// =============================================================
const { test, expect } = require('@playwright/test');
const BASE = 'http://localhost:3000';
 
test('模擬後端掛掉,驗證畫面的錯誤處理', async ({ page, context }) => {
  // 注意參數列:這條測試沒有要 request——因為根本不打真的 API
  await context.addCookies([{ name: 'auth', value: 'demo-token-123', url: BASE }]);
 
  // 設路障:凡是打向 /api/orders 的請求,一律在瀏覽器端就攔下,
  // 用 fulfill 直接回一個假的 500。真實後端連請求都收不到。
  // '**/api/orders*' 是網址比對模式:** 任意開頭、* 任意結尾
  await page.route('**/api/orders*', route =>
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'internal error' }),
    })
  );
 
  // 進頁面:頁面照常發請求 → 撞上路障 → 收到假 500
  await page.goto(`${BASE}/orders`);
 
  // 驗證前端的教養:收到 500 該顯示友善訊息,而不是白畫面或當掉。
  // 這個分支平常根本測不到——真實後端好好的,誰壞給你看?
  await expect(page.locator('#error')).toHaveText('訂單載入失敗,請稍後再試');
});

注意這條測試根本沒碰真的後端——所以它不是在測 API,是在測「API 壞掉時,前端的教養」。這正是攔截和前四招的分界:前四招驗真實系統,攔截驗的是前端在人造情境下的反應。兩件事都該做,但別搞混:全部 mock 掉的測試跑得再綠,也不代表真實系統是好的。


上一篇
Day17: 彈窗、iframe、新分頁與日期選擇器:那些讓人頭痛的畫面元素
下一篇
Day19: 截圖與視覺比對:畫面長歪了怎麼抓
系列文
AI 時代下最值得投資的 UI 自動化:30 天用 Claude Code 學會寫 Playwright19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言