結論先講

E2E 測試的目的不是「測所有東西」,是「確保關鍵流程不炸」。 登入、結帳、註冊——這些壞掉公司就會損失錢的流程,才值得寫 E2E。Playwright 是目前最好的選擇:速度快、多瀏覽器、有 codegen、auto-wait 機制讓測試不容易 flaky。

上一篇 Unit Test 實戰 講了怎麼測函式。這篇講怎麼測「使用者操作」。


為什麼是 Playwright 不是 Cypress

比較項目PlaywrightCypress
多瀏覽器Chromium + Firefox + WebKit主要 Chromium(Firefox 實驗性)
速度快(平行、headless 優化)較慢(單一 event loop)
多分頁/多視窗原生支援不支援
Auto-wait內建,幾乎不需要手動等待有,但不如 Playwright 穩
Codegennpx playwright codegen沒有
API 測試內建 request context需要第三方套件
iFrame原生支援難搞
學習曲線中等低(但進階功能受限)

簡單講:Cypress 適合快速上手的小專案。Playwright 適合需要跨瀏覽器、多分頁、CI 整合的正式專案。


從零開始

# 新專案
mkdir my-e2e && cd my-e2e
npm init -y
 
# 安裝 Playwright(會自動下載瀏覽器)
npm init playwright@latest

它會問你幾個問題,預設就好。安裝完你會得到:

my-e2e/
├── tests/
│   └── example.spec.ts
├── playwright.config.ts
└── package.json

跑第一個測試

npx playwright test

看結果:

npx playwright show-report

Codegen:讓 Playwright 幫你寫測試

這是 Playwright 最殺的功能。你操作瀏覽器,它自動產生測試程式碼:

npx playwright codegen localhost:3000

會打開兩個視窗——一個瀏覽器讓你操作,一個編輯器即時顯示產生的程式碼。點按鈕、填表單、做 assertion,全部自動產生。

注意:codegen 產生的程式碼是「能跑」,但不是「好的測試」。拿來當起點,然後手動整理——拆成 Page Object、改用穩定的 selector、加上有意義的 assertion。


寫一個真實的測試

假設我們在測一個 Todo App:

// tests/todo.spec.ts
import { test, expect } from '@playwright/test';
 
test.describe('Todo App', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000');
  });
 
  test('可以新增 todo', async ({ page }) => {
    await page.getByPlaceholder('What needs to be done?').fill('寫 E2E 測試');
    await page.getByPlaceholder('What needs to be done?').press('Enter');
 
    await expect(page.getByTestId('todo-item')).toHaveText('寫 E2E 測試');
  });
 
  test('可以完成 todo', async ({ page }) => {
    // 先新增
    await page.getByPlaceholder('What needs to be done?').fill('買牛奶');
    await page.getByPlaceholder('What needs to be done?').press('Enter');
 
    // 勾選完成
    await page.getByRole('checkbox').click();
 
    // 驗證
    await expect(page.getByTestId('todo-item')).toHaveClass(/completed/);
  });
 
  test('可以刪除 todo', async ({ page }) => {
    await page.getByPlaceholder('What needs to be done?').fill('刪掉我');
    await page.getByPlaceholder('What needs to be done?').press('Enter');
 
    await page.getByTestId('todo-item').hover();
    await page.getByRole('button', { name: 'Delete' }).click();
 
    await expect(page.getByTestId('todo-item')).toHaveCount(0);
  });
});

常用 Assertion

// 元素可見
await expect(page.getByText('歡迎')).toBeVisible();
 
// 文字內容
await expect(page.getByTestId('title')).toHaveText('首頁');
 
// URL
await expect(page).toHaveURL(/\/dashboard/);
 
// 元素數量
await expect(page.getByRole('listitem')).toHaveCount(3);
 
// Input 值
await expect(page.getByLabel('Email')).toHaveValue('terry@example.com');
 
// 元素屬性
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('button')).toBeDisabled();

Page Object Model:測試不會變成義大利麵

測試一多,重複的操作會到處都是。Page Object Model(POM)把頁面操作封裝成 class:

// pages/todo-page.ts
import { type Page, type Locator } from '@playwright/test';
 
export class TodoPage {
  private readonly input: Locator;
  private readonly items: Locator;
 
  constructor(private readonly page: Page) {
    this.input = page.getByPlaceholder('What needs to be done?');
    this.items = page.getByTestId('todo-item');
  }
 
  async goto() {
    await this.page.goto('http://localhost:3000');
  }
 
  async addTodo(text: string) {
    await this.input.fill(text);
    await this.input.press('Enter');
  }
 
  async completeTodo(index: number) {
    await this.items.nth(index).getByRole('checkbox').click();
  }
 
  async deleteTodo(index: number) {
    await this.items.nth(index).hover();
    await this.items.nth(index).getByRole('button', { name: 'Delete' }).click();
  }
 
  get todoItems() {
    return this.items;
  }
}

測試變乾淨了:

// tests/todo-pom.spec.ts
import { test, expect } from '@playwright/test';
import { TodoPage } from '../pages/todo-page';
 
test.describe('Todo App (POM)', () => {
  let todoPage: TodoPage;
 
  test.beforeEach(async ({ page }) => {
    todoPage = new TodoPage(page);
    await todoPage.goto();
  });
 
  test('新增 + 完成 + 刪除', async () => {
    await todoPage.addTodo('學 Playwright');
    await expect(todoPage.todoItems).toHaveCount(1);
 
    await todoPage.completeTodo(0);
    await expect(todoPage.todoItems.first()).toHaveClass(/completed/);
 
    await todoPage.deleteTodo(0);
    await expect(todoPage.todoItems).toHaveCount(0);
  });
});

處理登入:storageState

E2E 測試最煩的就是每個測試都要登入。Playwright 用 storageState 把登入狀態存下來,後續測試直接載入:

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
 
const authFile = 'playwright/.auth/user.json';
 
setup('登入', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('密碼').fill('password123');
  await page.getByRole('button', { name: '登入' }).click();
 
  await expect(page).toHaveURL('/dashboard');
 
  // 存下登入狀態
  await page.context().storageState({ path: authFile });
});

playwright.config.ts 設定:

import { defineConfig } from '@playwright/test';
 
export default defineConfig({
  projects: [
    // 先跑登入
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
 
    // 其他測試用登入後的狀態
    {
      name: 'chromium',
      use: {
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

這樣所有測試都不用重複登入。快非常多。


失敗時自動截圖 + 錄影

playwright.config.ts

export default defineConfig({
  use: {
    // 失敗時截圖
    screenshot: 'only-on-failure',
 
    // 失敗時錄影(第一次重試時錄)
    video: 'on-first-retry',
 
    // Trace(最強大的 debug 工具)
    trace: 'on-first-retry',
  },
 
  // 失敗自動重試一次
  retries: 1,
});

失敗後用 trace viewer 看完整操作過程:

npx playwright show-trace test-results/todo-test/trace.zip

Trace viewer 會顯示每一步的截圖、DOM snapshot、console log、network request——比看截圖強太多了。


CI 整合

GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
    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
 
      - name: Start app
        run: npm run dev &
 
      - name: Wait for app
        run: npx wait-on http://localhost:3000 --timeout 30000
 
      - name: Run E2E tests
        run: npx playwright test
 
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

GitLab CI

# .gitlab-ci.yml
e2e:
  image: mcr.microsoft.com/playwright:v1.52.0-jammy
  stage: test
  script:
    - npm ci
    - npm run dev &
    - npx wait-on http://localhost:3000 --timeout 30000
    - npx playwright test
  artifacts:
    when: always
    paths:
      - playwright-report/
    expire_in: 7 days

Playwright 的 Docker image 已經包含所有瀏覽器,不需要在 CI 裡另外裝。


常見錯誤

1. Flaky Test(時好時壞)

最大元兇是「沒有等」。Playwright 的 auto-wait 已經很好了,但有些情況還是需要手動處理:

// ❌ 不好:硬等
await page.waitForTimeout(3000);
 
// ✅ 好:等具體條件
await expect(page.getByText('載入完成')).toBeVisible();
await page.waitForResponse('**/api/data');

2. 脆弱的 Selector

// ❌ 不好:CSS selector 會因為 UI 改版壞掉
await page.click('.btn-primary.mt-4.mb-2');
await page.click('#app > div:nth-child(3) > button');
 
// ✅ 好:用語意化 selector
await page.getByRole('button', { name: '送出' }).click();
await page.getByTestId('submit-button').click();
await page.getByLabel('Email').fill('test@example.com');

Playwright 的 selector 優先順序:getByRole > getByText > getByTestId > CSS selector。

3. 測太多東西

// ❌ 不好:一個測試做太多事
test('完整購物流程', async ({ page }) => {
  // 登入 → 搜尋 → 加入購物車 → 結帳 → 確認訂單 → 查看訂單
  // 300 行程式碼...壞了不知道壞在哪
});
 
// ✅ 好:拆開
test('搜尋商品', async ({ page }) => { /* ... */ });
test('加入購物車', async ({ page }) => { /* ... */ });
test('結帳流程', async ({ page }) => { /* ... */ });

4. 測試之間有依賴

// ❌ 不好:測試 B 假設測試 A 已經跑過
test('A: 新增商品', async ({ page }) => { /* 新增 */ });
test('B: 刪除商品', async ({ page }) => { /* 假設 A 的商品還在 */ });
 
// ✅ 好:每個測試自己準備資料
test('刪除商品', async ({ page }) => {
  // 先透過 API 直接建資料
  await page.request.post('/api/products', { data: { name: 'test' } });
  // 再測刪除
});

下一步

Unit test(上一篇)測函式邏輯,E2E 測使用者流程。中間還有 integration test、contract test(API 契約測試)補上 API 層的驗證。

整個測試體系的設計邏輯,可以回頭看 為什麼要做壓力測試,從「為什麼要測」的角度理解每層測試的定位。


本系列文章

  1. 測試策略(一):測試金字塔
  2. 測試策略(二):Unit vs Integration
  3. 測試策略(三):E2E + 壓力測試
  4. CD 整合
  5. API 契約測試
  6. 安全測試
  7. Smoke + 回歸測試
  8. 測試資料管理
  9. 視覺回歸測試
  10. AI 輔助測試
  11. ISO 29119)
  12. 測試相關憑證與學習路徑
  13. Unit Test 實戰:Jest 和 Vitest
  14. E2E 測試實戰:Playwright(本篇)