如果你以為 Playwright 只能開瀏覽器點來點去,這篇會打開新世界:它也能直接呼叫 API。更重要的是兩者的混搭——用 API 佈景、用 UI 驗戲。這是讓測試速度翻倍、穩定度翻倍的關鍵一手。
這篇跟前面幾篇不一樣:不靠官方練習站,我們自己動手建一個迷你受測系統。跟 Claude Code 說一句話就能生出來,之後所有練習都打這個系統。你們公司系統有 API 的話,套路直接搬過去用。
API 是什麼,用你熟的話說
你在畫面上按「送出訂單」,瀏覽器背後其實是對伺服器發了一個請求:「幫我建一筆訂單,內容如下」。那個請求走的通道,就是 API。
UI 是給人看的皮,API 是系統之間講話的管道。同一件事,可以請人透過畫面做,也可以直接對管道說。
圖 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 的專屬協定,就換工具。
圖 2:Playwright 測 API 的能力範圍

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

圖 3:準備與清理走 API,重點驗證走 UI
假設要測訂單列表的分頁,列表得先有二十幾筆訂單才分得了頁。天真的做法是用 UI 下二十次單,光準備就五分鐘,還沒開始測分頁。
混搭的做法:用 API 快速塞好二十五筆訂單(佈景),開瀏覽器測列表顯示與分頁(這才是戲),測完用 API 清掉資料(收尾)。
原則一句話:測試的重點走 UI(那是使用者體驗所在),前置與收尾走 API(那只是佈景,快穩優先)。哪段是重點、哪段是佈景,是測試設計的判斷,不是技術判斷。
業界常見的四種手法,一次盤點
把 API 和 UI 混著用,業界玩了十幾年,常見套路就四種。前面談的佈景清理是第一種,另外三種也值得認識,免得只會一招。

圖 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 下指令,寫出來再跟參考解對照——差異的地方,往往就是你學到東西的地方。
表 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,後面六個練習都建立在這個雙軌設計上。

圖 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 那行——下面這張圖值得多看一分鐘:

圖 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 背後發出的請求
前面都是「自己發請求」,接下來兩個練習換位置:請求由頁面自己發,測試站在線路上。差別在於一個只看、一個換掉,先看圖分清楚:

圖 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 掉的測試跑得再綠,也不代表真實系統是好的。