「測試都通過了,為什麼上線還是出問題?」
你寫了完美的單元測試,整合測試也都綠燈,但使用者還是回報:「我點了按鈕,什麼事都沒發生!」這時你才發現,原來是 API 的 URL 寫錯了一個字母...
這就是為什麼我們需要 E2E(End-to-End)測試!今天,讓我們預覽這個強大的測試武器。
基礎測試 → Kata 實戰 → 框架特色 → 整合部署
1-10 11-17 18-27 28-30
↓ 我們在這裡(Day 27)🎬
[=============================================>....]
經過 26 天的學習,我們已經建立了完整的測試金字塔。今天要站在金字塔頂端,俯瞰整個測試版圖!
想像一下,你是一個真實的使用者:
E2E 測試就是模擬這整個過程!
/\
/E2E\ ← 今天的主角!
/------\
/整合測試\
/----------\
/ 單元測試 \
/--------------\
工具 | 優點 | 缺點 | 適合場景 |
---|---|---|---|
Cypress | 開發體驗佳、除錯容易 | 只支援 Chromium | 中小型專案 |
Playwright | 跨瀏覽器、速度快 | 學習曲線較陡 | 大型專案 |
Selenium | 老牌、生態系完整 | 設定複雜 | 企業級應用 |
今天我們選擇 Playwright,因為它:
✅ 支援所有主流瀏覽器、速度快、穩定度高、與 Vitest 整合良好
# 安裝 Playwright
npm install -D @playwright/test
# 安裝瀏覽器
npx playwright install
// 建立 playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
讓我們為 Todo App 寫第一個 E2E 測試:
// 建立 e2e/todo.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Todo App E2E', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('complete todo workflow', async ({ page }) => {
// 1. 檢查頁面標題
await expect(page).toHaveTitle(/Todo App/)
// 2. 新增待辦事項
const input = page.locator('input[placeholder="What needs to be done?"]')
await input.fill('寫 E2E 測試')
await input.press('Enter')
// 3. 驗證待辦事項顯示
const todoItem = page.locator('.todo-item').first()
await expect(todoItem).toContainText('寫 E2E 測試')
// 4. 標記為完成
await todoItem.locator('input[type="checkbox"]').click()
await expect(todoItem).toHaveClass(/completed/)
// 5. 刪除待辦事項
await todoItem.hover()
await todoItem.locator('button.destroy').click()
await expect(todoItem).not.toBeVisible()
})
})
// 建立 e2e/pages/TodoPage.ts
import { Page, Locator } from '@playwright/test'
export class TodoPage {
readonly page: Page
readonly todoInput: Locator
readonly todoItems: Locator
readonly filterAll: Locator
readonly filterActive: Locator
readonly filterCompleted: Locator
constructor(page: Page) {
this.page = page
this.todoInput = page.locator('input[placeholder="What needs to be done?"]')
this.todoItems = page.locator('.todo-item')
this.filterAll = page.locator('a:text("All")')
this.filterActive = page.locator('a:text("Active")')
this.filterCompleted = page.locator('a:text("Completed")')
}
async goto() {
await this.page.goto('/')
}
async addTodo(text: string) {
await this.todoInput.fill(text)
await this.todoInput.press('Enter')
}
async toggleTodo(index: number) {
await this.todoItems.nth(index).locator('input[type="checkbox"]').click()
}
async deleteTodo(index: number) {
const item = this.todoItems.nth(index)
await item.hover()
await item.locator('button.destroy').click()
}
async filterBy(filter: 'all' | 'active' | 'completed') {
switch (filter) {
case 'all':
await this.filterAll.click()
break
case 'active':
await this.filterActive.click()
break
case 'completed':
await this.filterCompleted.click()
break
}
}
}
// 更新 e2e/todo-with-pom.spec.ts
import { test, expect } from '@playwright/test'
import { TodoPage } from './pages/TodoPage'
test.describe('Todo App with POM', () => {
let todoPage: TodoPage
test.beforeEach(async ({ page }) => {
todoPage = new TodoPage(page)
await todoPage.goto()
})
test('manage todos efficiently', async () => {
// 新增多個待辦事項
await todoPage.addTodo('E2E 測試基礎')
await todoPage.addTodo('進階測試技巧')
await todoPage.addTodo('部署與監控')
// 驗證數量
await expect(todoPage.todoItems).toHaveCount(3)
// 標記完成
await todoPage.toggleTodo(0)
await todoPage.toggleTodo(1)
// 篩選驗證
await todoPage.filterBy('completed')
await expect(todoPage.todoItems).toHaveCount(2)
await todoPage.filterBy('active')
await expect(todoPage.todoItems).toHaveCount(1)
})
})
E2E 測試還可以捕捉視覺問題:
// 建立 e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Visual Regression', () => {
test('todo app screenshot', async ({ page }) => {
await page.goto('/')
// 新增一些測試資料
await page.locator('input[placeholder="What needs to be done?"]').fill('視覺測試')
await page.keyboard.press('Enter')
// 截圖比較
await expect(page).toHaveScreenshot('todo-app.png')
})
})
// ❌ 錯誤:依賴固定等待時間
test('bad practice', async ({ page }) => {
await page.click('button')
await page.waitForTimeout(1000) // 避免使用
await expect(page.locator('.result')).toBeVisible()
})
// ✅ 正確:等待特定條件
test('good practice', async ({ page }) => {
await page.click('button')
await page.waitForSelector('.result', { state: 'visible' })
await expect(page.locator('.result')).toBeVisible()
})
今天我們預覽了 E2E 測試的強大功能:
單元測試 → 快速回饋、大量覆蓋
整合測試 → 模組協作、API 測試
E2E 測試 → 使用者視角、關鍵流程
小提醒:E2E 測試雖然強大,但執行時間較長。記得合理安排測試策略,確保關鍵流程都有保護!