FE Day 27 E2E 測試:Playwright
執行需求:CPU 可跑。昨天 Day 26 把圖片、字體與資源的優化走完,今天進入測試的最後一塊:E2E(End-to-End)測試。前幾天我們用 Vitest 做元件與 Hook 的單元測試(Day 15)、用 pytest 在 Next.js 的 API 路由做契約測試(Day 24),但這些都沒有涵蓋「真實瀏覽器裡使用者操作的行為」。今天會用 Playwright 1.5x 在 Next.js 15 的 App Router 專案上做完整的 E2E 測試:寫一支「使用者打開首頁 → 點選預約服務 → 選時段 → 填表 → 看到確認」的真實流程測試,並整合進 CI、用 storage state 處理登入狀態、最後討論常見踩雷與 race condition 的除錯方法。Playwright 是 2026 年最熱門的 E2E 框架,比 Cypress、Puppeteer 更通用、更穩定、跨瀏覽器支援更好。
引言
E2E 測試是「從使用者角度看系統」的一種驗證方式:實際打開瀏覽器、模擬鍵盤與滑鼠、斷言 DOM 內容、擷取截圖當證據。它跟單元測試、整合測試的差別是覆蓋範圍——單元測試只看一個函式、整合測試看一支 API,E2E 測試看整個應用從 UI 到後端的所有東西。E2E 測試的優勢:能抓到單元測試抓不到的整合 bug(CSS 沒套好、JavaScript 沒載入、表單欄位 name 拼錯、CORS 漏設定)。E2E 測試的弱點:跑得慢(單支 5-30 秒)、脆弱(DOM 改了就壞)、除錯成本高(測試失敗要看截圖重跑)。所以業界經驗法則是「E2E 測試只覆蓋關鍵流程、單元測試覆蓋所有邊界」,我們貫穿專案(Day 31-44)的測試配比也會照這個原則走。
Playwright 1.5x 是 Microsoft 維護的開源 E2E 框架,2026 年 3 月的主流版本是 1.50 系列。它最大的優勢有四點:跨瀏覽器(Chromium、WebKit、Firefox 三個核心都支援)、跨語言(TypeScript、Python、Java、.NET 都有 SDK)、自動等待(Playwright 會等到元素真的能互動才執行下一個動作,減少 flaky test)、trace viewer(失敗時可以重播整段互動過程)。對習慣 TypeScript 的前端工程師來說,Playwright 內建的 @playwright/test runner 比 pytest 更貼近前端工具鏈(Vite、Vitest、ESLint 同一套設定哲學)。今天就用 TypeScript 寫。
這篇的結構:先示範 Playwright 安裝與第一支測試,接著寫「預約流程」的完整測試,再示範 storage state 處理登入、CI 整合、除錯方法、最後整理常見坑。讀完之後你應該能獨立在 Next.js 15 專案建出一組能跑進 CI 的 E2E 測試。
安裝 Playwright 與第一支測試
Playwright 的 TypeScript SDK 用 pnpm 安裝,瀏覽器用 playwright install 自動下載。我們用「兩個 config 拆開」的慣例:playwright.config.ts 放全域設定、tests/e2e/ 放測試本體:
# 安裝 Playwright 與 @playwright/test runner
pnpm add -D @playwright/test@^1.50
# 下載三個瀏覽器(Chromium、WebKit、Firefox)
pnpm exec playwright install chromium webkit firefox
# 安裝作業系統依賴(macOS / Linux 需要,Windows 可跳過)
pnpm exec playwright install-deps
建立 playwright.config.ts。這個 config 決定「測試在哪裡跑、用什麼瀏覽器、trace 怎麼存、baseURL 是什麼」。我們指定 baseURL 用環境變數切換(CI 跑 localhost、本地開發可以指到 staging):
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
// 每支測試最多 30 秒,超時視為 flaky 失敗
timeout: 30_000,
// 預期測試總時間:CI 上 5 分鐘、本地 2 分鐘
expect: { timeout: 5_000 },
// 只在 CI 強制允許 retry,本地開發看到 retry 會誤以為「其實是壞的」
retries: process.env.CI ? 2 : 0,
// 平行數量:CI 跑 2,本地依 CPU 自動
workers: process.env.CI ? 2 : undefined,
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: {
// 用環境變數切換目標,預設本機開發伺服器
baseURL: process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000",
// trace 只在失敗時保留,CI artifact 不會爆
trace: "retain-on-failure",
video: "retain-on-failure",
screenshot: "only-on-failure",
// 預設 Chromium;CI matrix 再加 webkit / firefox
...devices["Desktop Chrome"],
},
// 本地開發時自動啟 Next.js,CI 改用 pnpm start 跑正式版
webServer: process.env.CI
? undefined
: {
command: "pnpm dev",
url: "http://127.0.0.1:3000",
reuseExistingServer: true,
timeout: 60_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
});
第一支測試用 @playwright/test 的 test runner 寫:
// tests/e2e/homepage.spec.ts
// 驗證首頁能載入、H1 正確、Hero 圖存在
import { test, expect } from "@playwright/test";
test.describe("首頁", () => {
test("載入並顯示歡迎標題", async ({ page }) => {
await page.goto("/");
// expect 會自動等到元素出現再斷言(最多 5 秒)
await expect(page).toHaveTitle(/預約管理系統/);
const h1 = page.getByRole("heading", { level: 1, name: "歡迎使用 Hao 預約管理系統" });
await expect(h1).toBeVisible();
// next/image 自動產生的 img
const heroImg = page.getByRole("img", { name: "預約管理系統示意" });
await expect(heroImg).toBeVisible();
});
test("health check 端點回 200", async ({ request }) => {
// request fixture 直接打 API,帶同一份 cookie
const response = await request.get("/api/health");
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toMatchObject({ status: "ok", service: "booking-web" });
});
});
第一支測試展示三件事:page.goto 開首頁;expect(...).toBeVisible() 自動等到元素出現才斷言;request.get 直接打 API(在 Playwright context 內,帶同一份 cookie)。執行:
# 直接執行,config 內 webServer 會自動啟 pnpm dev
pnpm exec playwright test tests/e2e/homepage.spec.ts
# 只跑 Chromium(CI 通常這樣開)
pnpm exec playwright test --project=chromium
# 開啟 HTML report 看 trace
pnpm exec playwright show-report
完整預約流程 E2E 測試
貫穿專案(Day 33、Day 34)會建立「預約系統」的兩條主流程:使用者從首頁進到服務清單、點選服務、看到時段、選時段、填寫聯絡資料、送出、看到「預約成功」畫面。今天把這條流程寫成 E2E 測試,並把 Day 31 定義的 mock 模式(無後端時走 lib/mock)用進來:
// tests/e2e/booking-flow.spec.ts
// 預約流程完整 E2E:UI 測試不依賴真實後端,走 Day 31 的 mock 模式
import { test, expect } from "@playwright/test";
test.describe("預約流程", () => {
test("從服務頁到預約成功", async ({ page }) => {
// 1. 進到服務頁
await page.goto("/services");
await expect(page.getByRole("heading", { level: 1, name: "預約服務" })).toBeVisible();
// 2. 點第一張服務卡片裡的「查看時段」連結
const firstCard = page.getByRole("article").first();
await firstCard.getByRole("link", { name: /查看時段/ }).click();
// 3. 等跳轉到 /services/[id],看到時段表
await expect(page).toHaveURL(/\/services\/[^/]+\/?$/);
await expect(page.getByRole("heading", { level: 2, name: "可預約時段" })).toBeVisible();
// 4. 點第一個可預約時段
const slot = page.getByRole("button", { name: /\d{2}:\d{2}/ }).first();
await slot.click();
// 5. 看到「填寫聯絡資訊」表單
await expect(page.getByRole("heading", { level: 2, name: "聯絡資訊" })).toBeVisible();
// 6. 填表(Day 34 會正式實作,這裡先測介面存在)
await page.getByLabel("姓名").fill("王小明");
await page.getByLabel("Email").fill("ming@example.com");
await page.getByLabel("電話").fill("0912345678");
await page.getByLabel("備註").fill("想諮詢一對一課程");
// 7. 送出
await page.getByRole("button", { name: "確認預約" }).click();
// 8. 看到確認畫面
await expect(page.getByRole("heading", { level: 1, name: "預約成功" })).toBeVisible();
await expect(page.getByText(/預約編號/)).toBeVisible();
// 9. 截圖當證據
await page.screenshot({ path: "artifacts/booking-success.png", fullPage: true });
});
});
這支測試展示 Playwright 的四個好用工具:getByRole / getByLabel 用 ARIA 角色與 label 抓元素,比 CSS selector 穩(UI 改 class 不會壞);expect.toBeVisible() 自動等到元素真的出現才斷言;toHaveURL(/regex/) 用 regex 驗證跳轉;page.screenshot({ fullPage: true }) 截整頁截圖。測試跑完把截圖存到 artifacts/,失敗時可以打開看實際狀況。
一個重要的細節:getByRole("button", { name: /\d{2}:\d{2}/ }) 用 regex 抓「14:00」「15:00」這類時段按鈕,比寫死 name: "預約 14:00" 更具適應力——前一天已經被預約、時段變少時測試仍能跑。
Storage State 處理登入狀態
預約管理後台(Day 35)是登入後才能用的頁面,每次測試都重新登入太慢且不穩定(登入 API 萬一變慢或壞掉,後台測試跟著掛)。Playwright 的 storage state 機制可以讓你「先登入一次、把 cookie 存檔、所有後續測試直接載入這個 cookie」。
先寫一支「建立 storage state」的腳本:
// tests/e2e/scripts/save-admin-state.ts
// 執行一次:手動登入、存 cookie 到 storage_state.json
// 用法:pnpm exec tsx tests/e2e/scripts/save-admin-state.ts
import { chromium } from "@playwright/test";
import path from "node:path";
const BASE = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000";
const STORAGE = path.join(__dirname, "..", "storage_state.json");
async function main(): Promise<void> {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ baseURL: BASE });
const page = await context.newPage();
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.ADMIN_EMAIL!);
await page.getByLabel("密碼").fill(process.env.ADMIN_PASSWORD!);
await page.getByRole("button", { name: "登入" }).click();
await page.waitForURL(/\/admin.*/);
// 把登入後的 cookie / localStorage 存到檔案
await context.storageState({ path: STORAGE });
await browser.close();
console.log(`storage state 寫入 ${STORAGE}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
把所有需要登入狀態的測試加上一個「已登入 admin」的 project,並讓該 project 載入 storage state:
// playwright.config.ts(節錄:在 projects 加上 admin 用 project)
export default defineConfig({
// ...前面的設定略
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{
name: "admin-chromium",
use: {
...devices["Desktop Chrome"],
storageState: "tests/e2e/storage_state.json",
},
// 確保 storage state 存在,否則先跳過
testMatch: /admin\..*\.spec\.ts/,
},
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
});
這樣一來,後台相關的測試(例如 tests/e2e/admin.bookings.spec.ts)只要檔名有 admin. 前綴,Playwright 就會自動套用 admin-chromium project,直接用預先登入好的 cookie。實務上 cookie 大約 7 天有效,到期前重新跑 save-admin-state.ts 即可。
CI 整合:GitHub Actions
把 Playwright 跑進 CI 是 E2E 測試的終點:commit 進 main 時自動跑、CI 跑不通就擋 PR。Playwright 提供 playwright/ci 範本,照著改:
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
with:
version: 10
- name: 安裝依賴
run: pnpm install --frozen-lockfile
- name: 編譯 Next.js
run: pnpm build
- name: 安裝 Playwright 瀏覽器
run: pnpm exec playwright install --with-deps chromium
- name: 啟動 Next.js(背景)
run: |
pnpm start &
npx wait-on http://127.0.0.1:3000
- name: 跑 E2E 測試
env:
CI: "true"
E2E_BASE_URL: http://127.0.0.1:3000
run: pnpm exec playwright test --project=chromium
- name: 上傳測試結果
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
artifacts/
幾個關鍵設定:用 pnpm start 起正式版伺服器(pnpm dev 在 CI 會引入 hot reload 干擾、且首次編譯很慢);用 wait-on 等到 HTTP 200 再跑測試(比 sleep 5 精準);用 actions/upload-artifact@v4 把 screenshot / trace / video 上傳,PR 失敗時可以下載看。
Trace Viewer 與除錯
E2E 測試失敗時最痛的場景:「跑了 60 秒,看到紅字但不知道哪裡壞」。Playwright 的 trace viewer 是解藥:錄下測試的每個動作、每個 DOM snapshot、每個 console log、每個 network request,做成 HTML 報告。config 裡已經設 trace: "retain-on-failure",本地除錯時可以改成 "on" 強制保留:
// playwright.config.ts(除錯用片段)
export default defineConfig({
use: {
// 本地除錯時改這個,跑完自動開 HTML report
trace: "on",
// 慢動作(每個動作延遲 50ms)方便肉眼觀察
launchOptions: { slowMo: 50 },
},
});
另一個常用工具是 page.pause()。在測試某一段插入 page.pause(),Playwright 會打開 inspector 視窗顯示當前 DOM 與測試程式碼,讓你一步一步執行:
// tests/e2e/booking-flow.debug.spec.ts
// 除錯模式:本地跑時會彈出 Playwright Inspector
import { test, expect } from "@playwright/test";
test("debug 預約流程", async ({ page }) => {
await page.goto("/services");
await page.getByRole("article").first().click();
// 在這裡暫停,打開 Inspector 視窗
await page.pause();
await expect(page.getByRole("heading", { name: "可預約時段" })).toBeVisible();
});
跑 pnpm exec playwright test booking-flow.debug.spec.ts --headed(--headed 開啟可見瀏覽器),Inspector 視窗會自動開啟,讓你用「下一步」按鈕逐動執行測試,並隨時修改下一步的 selector。對付 flaky test 特別有用——可以直接看到「點按鈕時元素還沒 hydrate」這種時序問題。
Race Condition 與網路節流
Flaky test 八成來自 race condition:「測試程式繼續跑了,但 page 的 React 還沒 hydrate 完」。Playwright 的 auto-wait 處理了大部分,但偶爾會遇到「元素存在但 onClick 還沒綁好」。對策有三:
第一是「用 API mock 控制網路」。Playwright 內建 page.route 可以攔截特定 API 回應、自訂回應時間,模擬慢網路:
// tests/e2e/booking-flow.slow-network.spec.ts
// 模擬慢網路:每個 API 都延遲 500ms
import { test, expect, type Route } from "@playwright/test";
test("慢網路下仍能完成預約", async ({ page }) => {
// 攔截所有 /api/ 開頭的請求,延遲 500ms 再放行
await page.route("**/api/**", async (route: Route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await page.goto("/services");
await page.getByRole("article").first().getByRole("link").click();
await expect(page.getByRole("heading", { name: "可預約時段" })).toBeVisible();
});
第二是「用 Playwright 的 fulfill 直接回應」。在 mock 模式下可以完全取代真實 API 回應,讓測試不依賴後端:
// tests/e2e/services.mock-api.spec.ts
// 用 fulfill 攔截 /api/services,直接回 mock 資料
import { test, expect } from "@playwright/test";
test("API 失敗時顯示錯誤狀態", async ({ page }) => {
await page.route("**/api/services", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ message: "Internal Server Error" }),
}),
);
await page.goto("/services");
await expect(page.getByRole("alert")).toContainText("載入失敗");
});
第三是「禁用動畫」。CSS transition 與 animation 會讓測試看到的內容跟實際渲染不一致。在 Playwright 的 config 加 reducedMotion: "reduce",瀏覽器會自動把所有動畫時間壓成 0:
// playwright.config.ts(片段:禁用動畫)
export default defineConfig({
use: {
// 把 prefers-reduced-motion 強制設為 reduce
reducedMotion: "reduce",
// 也可以注入 CSS 直接把動畫時間歸零
// contextOptions: { reducedMotion: "reduce" },
},
});
常見錯誤與踩雷
第一個踩雷是「用 CSS selector 寫死」。寫 page.locator(".btn-primary") 看似直覺,但 UI 改 class 就壞。改用 getByRole("button", { name: "確認預約" }):即使 class 改了、只要 ARIA role 與可見文字沒變,測試就穩。
第二個是「忘了等 hydration」。page.goto("/services") 後伺服器回 HTML,但 React 還沒 hydrate 完成、onClick 還沒綁好。這時候點按鈕會發生「點了沒反應」。對策:用 expect.toBeVisible() 自動等到元素出現,再用 expect.toBeEnabled() 等按鈕變 enabled,最後才 click。如果還是有問題,加 await page.waitForLoadState("networkidle") 等所有 request 完成。
第三個是「時間相關的測試」。寫「今天 14:00 的時段」這種測試,今天能跑、明天就壞。對策:用 new Date() 動態計算測試時段的「下一天」或「兩天後」,避免依賴特定日期。Day 33 實作預約頁面時會大量用到這個技巧。
第四個是「storage state 過期」。Cookie 有時效(7-30 天),到 CI 上可能過期。對策:每次 CI 跑測試前重新跑 save-admin-state.ts;或者把 storage state 過期時間加進 health check,到期就 fail 提示更新。
第五個是「跨瀏覽器相容性」。Chromium / WebKit / Firefox 渲染行為有些微差異,特別是日期選擇器、tooltip。建議 CI 只跑 Chromium(速度最快)、其他瀏覽器每週排程跑一次,作為「相容性迴歸測試」。
第六個是「測試順序耦合」。如果 test_a 寫了一筆資料、test_b 預期看到那筆資料,兩個測試一換順序就壞。對策:每個測試用獨立 context(@playwright/test 預設行為)、每個測試先 reset 測試資料庫,或用 mock 模式確保每次都從乾淨狀態開始。
效能與實務提醒
E2E 測試的目標是「又快又穩」。一支 30 秒以上的測試會被工程師跳過執行,等於沒測。常見加速手段:把多個獨立檢查合併成一支測試(test_user_journey_completes 包 5 個步驟,比 test_step1、test_step2 省一半 setup 成本);用 storage state 省登入;用 page.route mock API 省後端往返;用 trace: "retain-on-failure" 失敗再錄,不要每支都錄。
另外一個重要觀念是「金字塔型測試組合」。單元測試(Vitest)要佔 70%、整合 / API 測試佔 20%、E2E 測試佔 10%。E2E 數量太多維護成本會爆炸,數量太少又抓不到整合問題。在 booking-web 專案的實務配比大概是:50 支 Vitest 單元測試、20 支 API 整合測試、10 支 Playwright E2E 測試。
最後一個提醒是「CI 跑的成本」。Playwright 跑一支約 5-15 秒,加上啟動瀏覽器約 10 秒,10 支 E2E 測試在 GitHub Actions 上要 2-3 分鐘。這時間不算貴但不少,建議用 matrix 把測試平行跑(3 個瀏覽器各跑一遍、總時間約 1 分鐘),或用 sharding 切成兩個 job 平行執行。
小結
今天把 Playwright 1.5x 的 E2E 測試走完一輪。重點回顧:Playwright 是 Microsoft 維護的跨瀏覽器 E2E 框架,搭配 TypeScript 的 @playwright/test runner;playwright.config.ts 提供 baseURL、trace、projects 的集中設定;getByRole + expect 是穩定的斷言 API;storage state 處理登入狀態;CI 整合用 GitHub Actions + pnpm start 跑正式版;trace viewer + page.pause() 是除錯雙神器;API mock 與 reducedMotion 是減少 flaky test 的兩條紀律。這套做完,貫穿專案就有完整的「單元 → 整合 → E2E」三層測試。
明天我們會進入另一個重要的品質主題:無障礙(a11y)。會從 WCAG 2.2 的四項基本原則(Perceivable、Operable、Understandable、Robust)講起,接著示範怎麼用 next/link / ARIA attributes / 鍵盤導航做「鍵盤可達」與「螢幕閱讀器友善」的介面,並用 axe-core + Playwright 跑 a11y 自動化檢查、最後整理常見的 a11y 盲點與修法。
結語
今天的重點是讓 E2E 測試從「聽起來很厲害但不會寫」變成「能在 Next.js 15 專案實際跑的工程實踐」。透過 playwright.config.ts 集中化、getByRole 穩定的元素定位、storage state 重用登入狀態、GitHub Actions 整合、trace viewer 除錯這五條武器,你應該能建立一套 5-15 支關鍵流程測試,能在 CI 自動跑、PR 失敗時自動把截圖與 trace 上傳回 GitHub Actions artifact 讓工程師除錯。讀完這篇你應該能回答:為什麼 Playwright 比 Cypress 好?怎麼在 TypeScript 環境用 Playwright?怎麼處理登入狀態?怎麼讓 E2E 測試穩定可跑?
明天,我們會用一整篇探討無障礙(a11y):WCAG 2.2 的四項原則、ARIA attributes 怎麼用、鍵盤導航怎麼設計、色彩對比度怎麼量測、用 axe-core + Playwright 自動化掃 a11y 問題。實作上會以「預約表單」當範例,把每個表單欄位的 label、錯誤訊息、required 屬性檢查一遍。
平行測試與 Sharding
當 E2E 測試的數量從 10 支長到 30 支以上,CI 上的總執行時間會明顯增加。Playwright 提供兩種加速手段:第一是 workers 平行數(同一台機器跑多個瀏覽器實例);第二是 sharding(把測試切給多台機器跑)。對 side project 來說,本地開發用預設的 worker 數即可(依 CPU 自動決定),CI 上可以強制 2 worker 並用 matrix 平行跑 3 個瀏覽器,總時間約可壓到 1 分鐘。
Sharding 的設定方式是透過 --shard 參數:
# 把測試切成 3 份,給 CI 的 3 個 job 各跑一份
pnpm exec playwright test --shard=1/3
pnpm exec playwright test --shard=2/3
pnpm exec playwright test --shard=3/3
# CI 上合併各 shard 的報告
pnpm exec playwright merge-reports --reporter=html ./all-reports
GitHub Actions 用 matrix 把三個 shard 各開一個 job 平行跑,最後用一個 merge job 把報告合起來。這套模式在大型 monorepo 的前端專案很常見,我們貫穿專案的測試規模用不到,但觀念先記起來——未來測試從 10 支長到 50 支時,sharding 是必要的手段。
Page Object Model:把重複邏輯抽出來
當多支測試都用同樣的「點卡片、選時段、填表」步驟時,重複的程式碼會開始堆積。Page Object Model(POM)是經典的解法:把頁面結構與互動封裝成 class,測試只描述「做什麼」、不關心「怎麼做」。Playwright 官方不強制 POM,但對中型專案來說能省下可觀的維護成本。
範例:把服務卡片與時段按鈕包成 ServicesPage:
// tests/e2e/pages/services-page.ts
// Page Object:把頁面互動封裝到 class,測試只呼叫語意化方法
import { type Page, expect } from "@playwright/test";
export class ServicesPage {
constructor(private readonly page: Page) {}
async goto(): Promise<void> {
await this.page.goto("/services");
await expect(
this.page.getByRole("heading", { level: 1, name: "預約服務" }),
).toBeVisible();
}
async openFirstService(): Promise<void> {
const card = this.page.getByRole("article").first();
await card.getByRole("link", { name: /查看時段/ }).click();
}
async pickFirstSlot(): Promise<void> {
const slot = this.page.getByRole("button", { name: /\d{2}:\d{2}/ }).first();
await slot.click();
}
async fillContactForm(input: {
name: string;
email: string;
phone: string;
notes?: string;
}): Promise<void> {
await this.page.getByLabel("姓名").fill(input.name);
await this.page.getByLabel("Email").fill(input.email);
await this.page.getByLabel("電話").fill(input.phone);
if (input.notes) {
await this.page.getByLabel("備註").fill(input.notes);
}
}
}
測試本體就會變得很短,意圖清楚:
// tests/e2e/booking-flow.pom.spec.ts
import { test, expect } from "@playwright/test";
import { ServicesPage } from "./pages/services-page";
test("用 POM 完成預約", async ({ page }) => {
const services = new ServicesPage(page);
await services.goto();
await services.openFirstService();
await services.pickFirstSlot();
await services.fillContactForm({
name: "王小明",
email: "ming@example.com",
phone: "0912345678",
notes: "想諮詢一對一課程",
});
await page.getByRole("button", { name: "確認預約" }).click();
await expect(page.getByRole("heading", { level: 1, name: "預約成功" })).toBeVisible();
});
POM 不是萬靈丹。對只有 5-10 支測試的小型專案來說,POM 反而會增加「要為一個簡單動作去翻 class 才知道怎麼寫」的成本。我們的判斷原則:當同一段 selector(如 page.getByRole("article").first().getByRole("link", ...))在 3 支以上的測試出現,就抽出來。貫穿專案的預約流程在 Day 33 與 Day 34 會大量重複,這個邊界很容易踩到,到時候再決定是否要為預約流程建一個 BookingFlowPage。
延伸資源
- Playwright 官方文件(2026 年 3 月 1.5x 系列):
https://playwright.dev/,定位、expect API、trace viewer。 - Playwright — Storage state:
https://playwright.dev/docs/auth,storage_state 參數、登入流程。 - axe-core — a11y 自動化檢查:
https://github.com/dequelabs/axe-core,Playwright 整合看@axe-core/playwright。 - Playwright — CI 與 sharding:
https://playwright.dev/docs/test-runners,GitHub Actions matrix 與 sharding 範例。 - @playwright/test runner:
https://playwright.dev/docs/test-runners,fixture、test.describe、annotation 用法。 - Playwright — Page Object Model:
https://playwright.dev/docs/pom,class 封裝頁面互動的範例。
留言
張貼留言