結論先講
E2E 測試的目的不是「測所有東西」,是「確保關鍵流程不炸」。 登入、結帳、註冊——這些壞掉公司就會損失錢的流程,才值得寫 E2E。Playwright 是目前最好的選擇:速度快、多瀏覽器、有 codegen、auto-wait 機制讓測試不容易 flaky。
上一篇 Unit Test 實戰 講了怎麼測函式。這篇講怎麼測「使用者操作」。
為什麼是 Playwright 不是 Cypress
| 比較項目 | Playwright | Cypress |
|---|---|---|
| 多瀏覽器 | Chromium + Firefox + WebKit | 主要 Chromium(Firefox 實驗性) |
| 速度 | 快(平行、headless 優化) | 較慢(單一 event loop) |
| 多分頁/多視窗 | 原生支援 | 不支援 |
| Auto-wait | 內建,幾乎不需要手動等待 | 有,但不如 Playwright 穩 |
| Codegen | npx 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-reportCodegen:讓 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.zipTrace 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 daysPlaywright 的 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 層的驗證。
整個測試體系的設計邏輯,可以回頭看 為什麼要做壓力測試,從「為什麼要測」的角度理解每層測試的定位。