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 只覆蓋核心使用者流程:註冊/登入、核心業務閉環、支付流程。
相關工具
- JSON 格式化 — 格式化 API Mock 資料
- 文字差異對比 — 對比測試輸出與期望值
- Base64 編碼解碼 — 處理截圖與檔案上傳測試
本站提供瀏覽器本地工具,免註冊即可試用 →