跳至主要内容

E2E 測試

本文檔說明花店管理系統的端對端(E2E)測試策略與實作方式。

測試工具

  • Playwright - 跨瀏覽器自動化測試框架
  • 支援瀏覽器:Chromium、Firefox、WebKit

安裝與設定

安裝 Playwright

cd app-web
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:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},

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:5173',
reuseExistingServer: !process.env.CI,
},
});

測試結構

e2e/
├── fixtures/
│ └── auth.ts # 認證 fixture
├── pages/
│ ├── LoginPage.ts # Page Object
│ ├── ProductsPage.ts
│ └── OrdersPage.ts
├── tests/
│ ├── auth.spec.ts # 認證測試
│ ├── products.spec.ts # 商品測試
│ └── orders.spec.ts # 訂單測試
└── utils/
└── helpers.ts # 測試工具

Page Object 模式

定義 Page Object

// pages/ProductsPage.ts
import { Page, Locator } from '@playwright/test';

export class ProductsPage {
readonly page: Page;
readonly searchInput: Locator;
readonly createButton: Locator;
readonly productTable: Locator;

constructor(page: Page) {
this.page = page;
this.searchInput = page.getByPlaceholder('搜尋商品');
this.createButton = page.getByRole('button', { name: '新增商品' });
this.productTable = page.getByRole('table');
}

async goto() {
await this.page.goto('/products');
}

async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press('Enter');
}

async createProduct() {
await this.createButton.click();
}

async getProductCount() {
return this.productTable.getByRole('row').count() - 1; // 扣除 header
}

async selectProduct(name: string) {
await this.page.getByRole('cell', { name }).click();
}
}

認證 Fixture

// fixtures/auth.ts
import { test as base } from '@playwright/test';

type AuthFixture = {
authenticatedPage: Page;
};

export const test = base.extend<AuthFixture>({
authenticatedPage: async ({ page }, use) => {
// 登入
await page.goto('/login');
await page.getByLabel('帳號').fill('admin');
await page.getByLabel('密碼').fill('admin123');
await page.getByRole('button', { name: '登入' }).click();

// 等待登入完成
await page.waitForURL('/');

await use(page);
},
});

export { expect } from '@playwright/test';

撰寫測試

認證測試

// tests/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('認證', () => {
test('登入成功', async ({ page }) => {
await page.goto('/login');

await page.getByLabel('帳號').fill('admin');
await page.getByLabel('密碼').fill('admin123');
await page.getByRole('button', { name: '登入' }).click();

await expect(page).toHaveURL('/');
await expect(page.getByText('歡迎回來')).toBeVisible();
});

test('登入失敗 - 密碼錯誤', async ({ page }) => {
await page.goto('/login');

await page.getByLabel('帳號').fill('admin');
await page.getByLabel('密碼').fill('wrong-password');
await page.getByRole('button', { name: '登入' }).click();

await expect(page.getByText('帳號或密碼錯誤')).toBeVisible();
});

test('登出', async ({ page }) => {
// 先登入
await page.goto('/login');
await page.getByLabel('帳號').fill('admin');
await page.getByLabel('密碼').fill('admin123');
await page.getByRole('button', { name: '登入' }).click();

// 登出
await page.getByRole('button', { name: '登出' }).click();

await expect(page).toHaveURL('/login');
});
});

商品管理測試

// tests/products.spec.ts
import { test, expect } from '../fixtures/auth';
import { ProductsPage } from '../pages/ProductsPage';

test.describe('商品管理', () => {
test('列出所有商品', async ({ authenticatedPage }) => {
const productsPage = new ProductsPage(authenticatedPage);
await productsPage.goto();

await expect(productsPage.productTable).toBeVisible();
expect(await productsPage.getProductCount()).toBeGreaterThan(0);
});

test('搜尋商品', async ({ authenticatedPage }) => {
const productsPage = new ProductsPage(authenticatedPage);
await productsPage.goto();

await productsPage.search('玫瑰');

await expect(authenticatedPage.getByText('玫瑰花束')).toBeVisible();
});

test('新增商品', async ({ authenticatedPage }) => {
const productsPage = new ProductsPage(authenticatedPage);
await productsPage.goto();

await productsPage.createProduct();

// 填寫表單
await authenticatedPage.getByLabel('SKU').fill('TEST-001');
await authenticatedPage.getByLabel('商品名稱').fill('測試商品');
await authenticatedPage.getByLabel('價格').fill('100');
await authenticatedPage.getByRole('button', { name: '儲存' }).click();

// 驗證成功
await expect(authenticatedPage.getByText('商品已建立')).toBeVisible();
});
});

執行測試

執行所有測試

npx playwright test

執行特定測試

# 執行特定檔案
npx playwright test tests/auth.spec.ts

# 執行特定測試
npx playwright test -g "登入成功"

互動模式

npx playwright test --ui

除錯模式

npx playwright test --debug

查看報告

npx playwright show-report

CI 整合

GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests

on:
push:
branches: [develop, main]
pull_request:
branches: [develop]

jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Install dependencies
run: |
cd app-web
npm ci

- name: Install Playwright browsers
run: |
cd app-web
npx playwright install --with-deps

- name: Run E2E tests
run: |
cd app-web
npm run test:e2e

- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: app-web/playwright-report/

最佳實踐

1. 使用可靠的選擇器

// ✅ 好:使用語意化選擇器
await page.getByRole('button', { name: '儲存' });
await page.getByLabel('帳號');
await page.getByText('歡迎');

// ❌ 避免:使用脆弱的選擇器
await page.locator('.btn-primary');
await page.locator('#submit-btn');

2. 等待適當的狀態

// ✅ 好:等待元素狀態
await expect(page.getByText('載入中')).toBeHidden();
await expect(page.getByRole('table')).toBeVisible();

// ❌ 避免:使用固定等待
await page.waitForTimeout(3000);

3. 隔離測試資料

每個測試應使用獨立的測試資料,避免測試間相互影響。

下一步