Playwright E2E 測試深度實戰:從用例編寫到 CI 整合

前端工程

為什麼是 Playwright,而不是 Cypress 或 Selenium

我在三個專案裡分別用了 Selenium、Cypress 和 Playwright 做 E2E 測試。Selenium 的體驗最痛苦——WebDriver 協定慢、等待邏輯全靠 sleep、瀏覽器版本匹配像拆炸彈。Cypress 在中型專案跑了大半年,直到有一天被測頁面開了三個 window.open,Cypress 直接罷工——它不支援多分頁。

Playwright 解決了這些痛點。它由同一批從 Google 出來的 Puppeteer 團隊開發,原生支援 Chromium、Firefox 和 WebKit,API 設計的一致性讓人舒服。更關鍵的是它有自動等待機制——點選按鈕前自動等元素可見且可互動,不需要手寫 waitForSelector

這篇文章不是 API 翻譯。我會聚焦在實際專案中最常用的模式,以及團隊從 0 到 50 個測試案例的經驗總結。


環境搭建與專案結構

npm init playwright@latest

生成的專案結構:

e2e/
├── tests/
│   ├── example.spec.ts
│   ├── login.spec.ts
│   └── dashboard.spec.ts
├── fixtures/
│   └── auth.setup.ts
├── pages/
│   ├── LoginPage.ts
│   └── DashboardPage.ts
├── utils/
│   └── helpers.ts
└── playwright.config.ts

核心配置

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e/tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 3 : undefined,
  reporter: [
    ['html', { outputFolder: 'playwright-report' }],
    ['json', { outputFile: 'test-results.json' }],
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

重點解讀:

  • fullyParallel: true —— 所有測試檔案和案例完全並行,CI 中跑 50 個案例只需 2-3 分鐘
  • trace: 'on-first-retry' —— 失敗時自動記錄 trace,事後回放可以像看錄影一樣定位問題
  • webServer —— 自動啟動開發伺服器,不用手動 npm run dev

定位器策略:穩定性的第一道防線

不穩定的定位器是 E2E 測試的頭號殺手。DOM 結構一變,測試全紅。Playwright 提供了多層次的定位器方案,按推薦優先級排列:

優先級 定位器 範例 穩定性
最高 getByRole page.getByRole('button', { name: '提交' }) ✅ 極穩定
getByLabel page.getByLabel('使用者名稱') ✅ 穩定
getByPlaceholder page.getByPlaceholder('請輸入信箱') ✅ 穩定
getByText page.getByText('訂單詳情') ✅ 穩定
getByTestId page.getByTestId('submit-btn') ✅ 穩定
locator (CSS) page.locator('.login-form .submit') ⚠️ 易變
locator (XPath) page.locator('//div[@class="btn"]') ❌ 極易變

實戰對比

// ❌ 差:CSS 類名改了測試就掛
await page.locator('#app > div > form > button.btn-primary').click();

// ⚠️ 湊合:testId 穩定但需要改源碼加 data-testid
await page.getByTestId('login-submit').click();

// ✅ 好:基於無障礙語義,與 UI 框架無關
await page.getByRole('button', { name: '登入' }).click();

// ✅ 好:基於可訪問標籤
await page.getByLabel('信箱地址').fill('test@example.com');
await page.getByPlaceholder('請輸入密碼').fill('Secret123');

過濾與鏈式定位

// 找到列表中的特定項
const item = page.getByRole('listitem').filter({ hasText: '訂單 #2024' });

// 精確文字 vs 模糊匹配
page.getByText('刪除', { exact: true });    // 只匹配"刪除"
page.getByText('刪除');                      // 匹配包含"刪除"的元素

斷言策略:別什麼都 assert

E2E 測試裡最常犯的錯誤是過度斷言。每個步驟都 expect 一遍,測試可讀性差,維護成本高。

推薦策略:只斷言使用者能感知的

test('使用者登入成功後跳轉到儀表板', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('信箱').fill('admin@example.com');
  await page.getByLabel('密碼').fill('password123');

  // 關鍵斷言:使用者能看到的最終結果
  await page.getByRole('button', { name: '登入' }).click();
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('歡迎回來,Admin')).toBeVisible();

  // 反向斷言:確認不該出現的元素不存在
  await expect(page.getByText('登入失敗')).not.toBeVisible();
});

網路攔截與 Mock:不用改後端也能測試

test('展示使用者列表——Mock 後端資料', async ({ page }) => {
  await page.route('**/api/users**', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        data: [
          { id: 1, name: 'Alice', email: 'alice@example.com' },
          { id: 2, name: 'Bob', email: 'bob@example.com' },
        ],
        total: 2,
      }),
    });
  });

  await page.goto('/users');
  await expect(page.getByText('Alice')).toBeVisible();
});

模擬網路異常

// 模擬 500 錯誤
await page.route('**/api/submit', async (route) => {
  await route.fulfill({
    status: 500,
    body: JSON.stringify({ error: 'Internal Server Error' }),
  });
});

// 模擬網路逾時
await page.route('**/api/slow-endpoint', async (route) => {
  await route.abort('timedout');
});

身份認證複用:每次測試都登入太慢了

// e2e/tests/auth.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('登入並儲存狀態', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('信箱').fill('admin@example.com');
  await page.getByLabel('密碼').fill('password123');
  await page.getByRole('button', { name: '登入' }).click();
  await expect(page).toHaveURL('/dashboard');
  await page.context().storageState({ path: authFile });
});

後續測試自動用 user.json 恢復登入態,秒進。


Page Object 模式

// e2e/pages/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(readonly page: Page) {
    this.emailInput = page.getByLabel('信箱');
    this.passwordInput = page.getByLabel('密碼');
    this.submitButton = page.getByRole('button', { name: '登入' });
    this.errorMessage = page.getByTestId('login-error');
  }

  async goto() { await this.page.goto('/login'); }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

CI/CD 整合

name: E2E Tests
on:
  pull_request:
    branches: [main]

jobs:
  e2e:
    timeout-minutes: 15
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Install dependencies
        run: npm ci
      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium
      - name: Run E2E tests
        run: npx playwright test --shard=${{ matrix.shard }}/3
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/
          retention-days: 7

常見問題

Q:Playwright 和 Cypress 怎麼選?

A:新專案直接選 Playwright。Cypress 的優勢是社群成熟度和 Dashboard 服務,但在技術層面——多分頁支援、多瀏覽器、自動等待、並行執行——Playwright 都更優。

Q:E2E 測試應該覆蓋多少?

A:推薦「測試金字塔」比例——單元測試 70%,整合測試 20%,E2E 測試 10%。E2E 只覆蓋核心使用者流程:註冊/登入、核心業務閉環、支付流程。


相關工具

本站提供瀏覽器本地工具,免註冊即可試用 →

#Playwright#E2E测试#自动化测试#前端#教程