跳至主要内容

測試

AppFuse Web 使用 Vitest 進行單元測試,Playwright 進行 E2E 測試。

測試架構

層級工具用途
單元測試Vitest元件、工具函數測試
E2E 測試Playwright完整流程測試
API 模擬MSWMock API

目錄結構

appfuse-web/
├── lib/
│ ├── __tests__/ # 單元測試設置
│ │ ├── setupTests.ts # 全域設置
│ │ └── helper/ # 測試輔助
│ └── **/*.test.tsx # 元件測試(就地放置)

├── tests/
│ ├── e2e/ # E2E 測試
│ │ └── *.spec.ts
│ ├── helpers/ # E2E 測試輔助
│ │ ├── login.ts
│ │ ├── form.ts
│ │ └── assertions.ts
│ └── fixtures/ # 測試資料
│ └── test-data.ts

├── vite.config.ts # Vitest 配置
└── playwright.config.ts # Playwright 配置

執行測試

# 單元測試
npm run test # 執行所有單元測試
npm run coverage # 生成覆蓋率報告

# E2E 測試
npm run test:e2e # 執行 E2E 測試
npm run test:e2e:ui # UI 模式(可視化除錯)
npm run test:e2e:debug # 除錯模式

單元測試 (Vitest)

配置

// vite.config.ts
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./lib/__tests__/setupTests.ts'],
include: ['lib/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
});

全域設置

// lib/__tests__/setupTests.ts
import '@testing-library/jest-dom';
import { afterEach, vi } from 'vitest';

afterEach(() => {
vi.clearAllMocks();
});

基本測試

import { describe, it, expect } from 'vitest';

describe('add function', () => {
it('should add two numbers', () => {
expect(add(1, 2)).toBe(3);
});
});

React 元件測試

import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './button';

describe('Button', () => {
it('should render with label', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});

it('should call onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();

render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));

expect(handleClick).toHaveBeenCalledTimes(1);
});

it('should be disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});

DataTable 測試範例

describe('DataTable', () => {
const mockData = [
{ id: '1', name: 'Alice', age: 25 },
{ id: '2', name: 'Bob', age: 30 },
];

const mockColumns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'age', header: 'Age' },
];

it('should render data', () => {
render(<DataTable data={mockData} columns={mockColumns} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});

it('should show empty message when no data', () => {
render(<DataTable data={[]} columns={mockColumns} emptyMessage="No data" />);
expect(screen.getByText('No data')).toBeInTheDocument();
});

it('should call onSortingChange when sorting', async () => {
const user = userEvent.setup();
const handleSort = vi.fn();

render(
<DataTable
data={mockData}
columns={mockColumns}
onSortingChange={handleSort}
/>
);

const sortButton = screen.getAllByRole('button', { name: /sort/i })[0];
await user.click(sortButton);

expect(handleSort).toHaveBeenCalled();
});
});

常用匹配器

匹配器用途
toBeInTheDocument()元素存在於 DOM
toHaveTextContent()檢查文字內容
toHaveValue()檢查輸入值
toBeVisible()元素可見
toBeDisabled()元素禁用
toHaveClass()檢查 CSS 類別

E2E 測試 (Playwright)

配置

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: 'html',

use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
locale: 'en-US',
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],

webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

基本測試

// tests/e2e/US-301-customer-crud.spec.ts
import { test, expect } from '@playwright/test';
import { loginAsOwner } from '../helpers/login';

test.describe('Customer CRUD', () => {
test.beforeEach(async ({ page }) => {
await loginAsOwner(page);
});

test('should create a new customer', async ({ page }) => {
// 導航到新增頁面
await page.goto('/customers/new');
await expect(page.getByText('Basic Information')).toBeVisible();

// 填寫表單
await page.getByLabel('Name').fill('測試客戶');
await page.getByLabel('Phone').fill('0912345678');

// 提交
await page.getByRole('button', { name: 'Save' }).click();

// 驗證成功
await expect(page).toHaveURL(/\/customers\/[^/]+$/);
});
});

測試輔助函數

登入輔助

// tests/helpers/login.ts
export const TEST_USERS = {
owner: { username: 'manager', password: 'Password123!' },
sales: { username: 'sales', password: 'Password123!' },
};

export async function login(page: Page, credentials: LoginCredentials) {
await page.goto('/login');

await page.locator('input[name="username"]').fill(credentials.username);
await page.locator('input[name="password"]').fill(credentials.password);
await page.locator('button[type="submit"]').click();

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

export async function loginAsOwner(page: Page) {
await login(page, TEST_USERS.owner);
}

表單輔助

// tests/helpers/form.ts
export async function fillInput(page: Page, label: string, value: string) {
const input = page.getByLabel(label);
await input.fill(value);
}

export async function selectOption(page: Page, label: string, optionText: string) {
const select = page.getByRole('combobox', { name: new RegExp(label, 'i') });
await select.click();
await page.getByRole('option', { name: optionText }).click();
}

export async function submitForm(page: Page) {
await page.locator('button[type="submit"]').click();
}

斷言輔助

// tests/helpers/assertions.ts
export async function expectSuccessToast(page: Page) {
const toast = page.locator('.alert.alert-success');
await expect(toast).toBeVisible({ timeout: 10000 });
}

export async function expectUrlMatches(page: Page, pattern: RegExp) {
await expect(page).toHaveURL(pattern);
}

export async function expectFormError(page: Page, errorText: string) {
const error = page.locator('.text-error').filter({ hasText: errorText });
await expect(error).toBeVisible();
}

測試資料

// tests/fixtures/test-data.ts
export const seedData = {
customers: {
liDahua: {
id: 'cust-123',
name: '李大華',
phone: '0912345678',
},
},
};

export function generateUniquePhone(): string {
const timestamp = Date.now().toString().slice(-6);
return `0999${timestamp}`;
}

選擇器優先順序

優先順序方法範例
1getByRolepage.getByRole('button', { name: 'Save' })
2getByLabelpage.getByLabel('Email')
3getByTextpage.getByText('Welcome')
4getByTestIdpage.getByTestId('order-list')

Web-First Assertions

// ✅ 正確:使用 Web-First Assertions
await expect(page.getByRole('table')).toBeVisible();
await expect(page.locator('.alert')).toHaveCount(1);

// ❌ 錯誤:使用 waitForTimeout
await page.waitForTimeout(3000);
await page.waitForLoadState('networkidle');

測試策略

單元測試覆蓋

  • 工具函數 - 100% 覆蓋
  • React Hooks - 主要路徑覆蓋
  • UI 元件 - 關鍵互動覆蓋

E2E 測試覆蓋

  • Happy Path - 主要功能流程
  • 邊界情況 - 表單驗證、錯誤處理
  • 權限測試 - 使用最大權限帳號

測試資料策略

  • MSW 每次頁面載入會重置資料
  • 使用 seedData 中已有的資料
  • 新建資料時用 Date.now() 確保唯一性

測試報告

單元測試覆蓋率

npm run coverage
# 輸出:coverage/index.html

E2E 測試報告

npx playwright show-report
# 輸出:playwright-report/
# 包含:截圖、錄影、Trace

最佳實踐

單元測試

  1. 就地放置 - 測試檔案與元件同目錄
  2. 測試行為 - 不要測試實作細節
  3. 使用 userEvent - 模擬真實使用者互動
  4. 清理副作用 - afterEach 中清除 mocks

E2E 測試

  1. 使用 Web-First Assertions - 不要用 waitForTimeout
  2. 優先用 getByRole - 更穩定、更具可讀性
  3. 抽取輔助函數 - 減少重複程式碼
  4. 獨立測試 - 每個測試不依賴其他測試的狀態

下一步