claude-code/claude-zh/skills/e2e-testing/SKILL.md

328 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: e2e-testing
description: Playwright E2E 測試模式,涵蓋頁面物件模型 (POM)、配置設定、CI/CD 整合、產出物 (Artifact) 管理以及不穩定 (Flaky) 測試策略。
---
# E2E 測試模式 (E2E Testing Patterns)
用於建構穩定、快速且易於維護的 E2E 測試套件的 Playwright 綜合模式。
## 測試檔案組織
```
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ ├── logout.spec.ts
│ │ └── register.spec.ts
│ ├── features/
│ │ ├── browse.spec.ts
│ │ ├── search.spec.ts
│ │ └── create.spec.ts
│ └── api/
│ └── endpoints.spec.ts
├── fixtures/
│ ├── auth.ts
│ └── data.ts
└── playwright.config.ts
```
## 頁面物件模型 (Page Object Model, POM)
```typescript
import { Page, Locator } from '@playwright/test'
export class ItemsPage {
readonly page: Page
readonly searchInput: Locator
readonly itemCards: Locator
readonly createButton: Locator
constructor(page: Page) {
this.page = page
this.searchInput = page.locator('[data-testid="search-input"]')
this.itemCards = page.locator('[data-testid="item-card"]')
this.createButton = page.locator('[data-testid="create-btn"]')
}
async goto() {
await this.page.goto('/items')
await this.page.waitForLoadState('networkidle')
}
async search(query: string) {
await this.searchInput.fill(query)
// 等待搜尋 API 回應
await this.page.waitForResponse(resp => resp.url().includes('/api/search'))
await this.page.waitForLoadState('networkidle')
}
async getItemCount() {
return await this.itemCards.count()
}
}
```
## 測試結構
```typescript
import { test, expect } from '@playwright/test'
import { ItemsPage } from '../../pages/ItemsPage'
test.describe('項搜尋', () => {
let itemsPage: ItemsPage
test.beforeEach(async ({ page }) => {
itemsPage = new ItemsPage(page)
await itemsPage.goto()
})
test('應能依關鍵字搜尋', async ({ page }) => {
await itemsPage.search('test')
const count = await itemsPage.getItemCount()
expect(count).toBeGreaterThan(0)
await expect(itemsPage.itemCards.first()).toContainText(/test/i)
await page.screenshot({ path: 'artifacts/search-results.png' })
})
test('應能處理無結果的情況', async ({ page }) => {
await itemsPage.search('xyznonexistent123')
await expect(page.locator('[data-testid="no-results"]')).toBeVisible()
expect(await itemsPage.getItemCount()).toBe(0)
})
})
```
## Playwright 配置
```typescript
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['junit', { outputFile: 'playwright-results.xml' }],
['json', { outputFile: 'playwright-results.json' }]
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
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,
timeout: 120000,
},
})
```
## 不穩定測試模式 (Flaky Test Patterns)
### 隔離區 (Quarantine)
```typescript
test('flaky: 複雜搜尋', async ({ page }) => {
test.fixme(true, '不穩定 - 議題 #123')
// 測試程式碼...
})
test('條件式略過', async ({ page }) => {
test.skip(process.env.CI, '在 CI 環境中不穩定 - 議題 #123')
// 測試程式碼...
})
```
### 識別不穩定性
```bash
# 重複執行 10 次以找出不穩定性
npx playwright test tests/search.spec.ts --repeat-each=10
npx playwright test tests/search.spec.ts --retries=3
```
### 常見原因與修正
**競態條件 (Race conditions)**
```typescript
// 不良做法:假設元素已就緒
await page.click('[data-testid="button"]')
// 推薦做法:使用自動等待定位器
await page.locator('[data-testid="button"]').click()
```
**網路時機問題:**
```typescript
// 不良做法:使用任意的等待時間
await page.waitForTimeout(5000)
// 推薦做法:等待特定的條件達成
await page.waitForResponse(resp => resp.url().includes('/api/data'))
```
**動畫時機問題:**
```typescript
// 不良做法:在動畫進行中點擊
await page.click('[data-testid="menu-item"]')
// 推薦做法:等待狀態穩定
await page.locator('[data-testid="menu-item"]').waitFor({ state: 'visible' })
await page.waitForLoadState('networkidle')
await page.locator('[data-testid="menu-item"]').click()
```
## 產出物 (Artifact) 管理
### 螢幕截圖
```typescript
await page.screenshot({ path: 'artifacts/after-login.png' })
await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true })
await page.locator('[data-testid="chart"]').screenshot({ path: 'artifacts/chart.png' })
```
### 追蹤軌跡 (Traces)
```typescript
await browser.startTracing(page, {
path: 'artifacts/trace.json',
screenshots: true,
snapshots: true,
})
// ... 測試行為 ...
await browser.stopTracing()
```
### 影片
```typescript
// 於 playwright.config.ts 設定
use: {
video: 'retain-on-failure',
videosPath: 'artifacts/videos/'
}
```
## CI/CD 整合
```yaml
# .github/workflows/e2e.yml
name: E2E 測試
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
BASE_URL: ${{ vars.STAGING_URL }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
```
## 測試報告範本
```markdown
# E2E 測試報告
**日期:** YYYY-MM-DD HH:MM
**耗時:** X分 Y秒
**狀態:** 通過 (PASSING) / 失敗 (FAILING)
## 摘要
- 總數X | 通過Y (Z%) | 失敗A | 不穩定B | 略過C
## 失敗的測試
### [測試名稱]
**檔案:** `tests/e2e/feature.spec.ts:45`
**錯誤:** 預期元素應為可見 (Visible)
**截圖:** artifacts/failed.png
**建議修正:** [描述]
## 產出物
- HTML 報告playwright-report/index.html
- 螢幕截圖artifacts/*.png
- 影片artifacts/videos/*.webm
- 追蹤軌跡artifacts/*.zip
```
## 錢包 / Web3 測試
```typescript
test('錢包連接', async ({ page, context }) => {
// 模擬錢包提供者 (Wallet Provider)
await context.addInitScript(() => {
window.ethereum = {
isMetaMask: true,
request: async ({ method }) => {
if (method === 'eth_requestAccounts')
return ['0x1234567890123456789012345678901234567890']
if (method === 'eth_chainId') return '0x1'
}
}
})
await page.goto('/')
await page.locator('[data-testid="connect-wallet"]').click()
await expect(page.locator('[data-testid="wallet-address"]')).toContainText('0x1234')
})
```
## 金融 / 關鍵流程測試
```typescript
test('交易執行', async ({ page }) => {
// 在生產環境中略過 — 避免真實金錢交易
test.skip(process.env.NODE_ENV === 'production', '在生產環境中略過')
await page.goto('/markets/test-market')
await page.locator('[data-testid="position-yes"]').click()
await page.locator('[data-testid="trade-amount"]').fill('1.0')
// 驗證預覽
const preview = page.locator('[data-testid="trade-preview"]')
await expect(preview).toContainText('1.0')
// 確認並等待區塊鏈處理
await page.locator('[data-testid="confirm-trade"]').click()
await page.waitForResponse(
resp => resp.url().includes('/api/trade') && resp.status() === 200,
{ timeout: 30000 }
)
await expect(page.locator('[data-testid="trade-success"]')).toBeVisible()
})
```