跳到主要內容

FE Day 27 E2E 測試:Playwright

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 封裝頁面互動的範例。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...