在 Day 20 完成前端架構盤點後,我們發現測試覆蓋率還需要提升。今天我們將使用 Vitest + React Testing Library 建立完整的前端測試框架,確保 Kyo-Dashboard 的品質與可靠性。
Vitest 是專為 Vite 設計的測試框架,契合我們的技術棧:
特性 | Vitest | Jest |
---|---|---|
與 Vite 整合 | ✅ 原生支援 | ❌ 需配置 |
ESM 支援 | ✅ 完整 | ⚠️ 實驗性 |
啟動速度 | ⚡ 極快 | 🐌 較慢 |
TypeScript | ✅ 零配置 | ⚠️ 需 ts-jest |
Watch 模式 | ✅ 智慧型 | ✅ 傳統 |
UI 介面 | ✅ 內建 | ❌ 無 |
選擇 Vitest 的理由:
// 1. 與 Vite 共用配置 - 無需重複設定
// 2. 極快的測試執行速度 - HMR 技術
// 3. 與 Jest API 相容 - 學習成本低
// 4. 內建 UI - 視覺化測試結果
# 安裝測試相關套件
pnpm add -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event happy-dom
# Vitest: 測試框架
# @vitest/ui: 測試 UI 介面
# @testing-library/react: React 測試工具
# @testing-library/jest-dom: DOM 斷言擴展
# @testing-library/user-event: 使用者互動模擬
# happy-dom: 輕量級 DOM 環境
// apps/kyo-dashboard/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000'
}
},
// 測試配置
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test/setup.ts'],
css: true, // 處理 CSS imports
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.test.{ts,tsx}',
'src/**/*.spec.{ts,tsx}',
'src/test/**',
'src/main.tsx',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});
{
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run"
}
}
apps/kyo-dashboard/
├── src/
│ ├── components/
│ │ ├── Members/
│ │ │ ├── MemberCard.tsx
│ │ │ └── MemberCard.test.tsx # 元件測試
│ │ └── TenantSwitcher.tsx
│ │
│ ├── pages/
│ │ ├── Login.tsx
│ │ └── Login.test.tsx # 頁面測試
│ │
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ └── useAuth.test.ts # Hooks 測試
│ │
│ ├── stores/
│ │ ├── authStore.ts
│ │ └── authStore.test.ts # Store 測試
│ │
│ └── test/
│ ├── setup.ts # 測試設定
│ ├── test-utils.tsx # 測試工具
│ └── mocks/ # Mock 資料
│ ├── handlers.ts # MSW handlers
│ └── server.ts # MSW server
│
└── vitest.config.ts
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';
// 每個測試後自動清理
afterEach(() => {
cleanup();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// src/test/test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
// 建立測試用 Query Client
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false, // 測試時不重試
cacheTime: 0,
staleTime: 0,
},
},
});
// 包裝所有必要的 Provider
interface AllProvidersProps {
children: React.ReactNode;
}
function AllProviders({ children }: AllProvidersProps) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<MantineProvider>
<BrowserRouter>
{children}
</BrowserRouter>
</MantineProvider>
</QueryClientProvider>
);
}
// 自訂 render 函數
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// 重新導出所有 testing-library 工具
export * from '@testing-library/react';
export { renderWithProviders as render };
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Mock OTP 發送 API
http.post('/api/otp/send', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
success: true,
msgId: 'mock-msg-id-' + Date.now(),
}, { status: 202 });
}),
// Mock OTP 驗證 API
http.post('/api/otp/verify', async ({ request }) => {
const body = await request.json();
const { code } = body as { code: string };
if (code === '123456') {
return HttpResponse.json({
valid: true,
});
}
return HttpResponse.json({
valid: false,
reason: 'invalid_code',
});
}),
// Mock 登入 API
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
const { email, password } = body as { email: string; password: string };
if (email === 'test@example.com' && password === 'password123') {
return HttpResponse.json({
token: 'mock-jwt-token',
user: {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
},
});
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
// Mock 租戶列表
http.get('/api/tenants', () => {
return HttpResponse.json([
{ id: 'gym-1', name: '健身房 A', role: 'admin' },
{ id: 'gym-2', name: '健身房 B', role: 'staff' },
]);
}),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// 在測試開始前啟動 server
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// 每個測試後重置 handlers
afterEach(() => server.resetHandlers());
// 測試結束後關閉 server
afterAll(() => server.close());
// src/components/TenantSwitcher.test.tsx
import { describe, test, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { TenantSwitcher } from './TenantSwitcher';
describe('TenantSwitcher', () => {
test('應該顯示租戶列表', async () => {
render(<TenantSwitcher />);
// 等待資料載入
await waitFor(() => {
expect(screen.getByText('健身房 A')).toBeInTheDocument();
});
expect(screen.getByText('健身房 B')).toBeInTheDocument();
});
test('應該可以切換租戶', async () => {
const user = userEvent.setup();
const onSwitch = vi.fn();
render(<TenantSwitcher onSwitch={onSwitch} />);
// 等待資料載入
await waitFor(() => {
expect(screen.getByText('健身房 A')).toBeInTheDocument();
});
// 點擊切換租戶
await user.click(screen.getByText('健身房 B'));
expect(onSwitch).toHaveBeenCalledWith({
id: 'gym-2',
name: '健身房 B',
role: 'staff',
});
});
test('應該在載入時顯示 loading 狀態', () => {
render(<TenantSwitcher />);
expect(screen.getByTestId('tenant-loading')).toBeInTheDocument();
});
test('應該處理錯誤狀態', async () => {
// 覆寫 mock 讓它返回錯誤
server.use(
http.get('/api/tenants', () => {
return HttpResponse.json(
{ error: 'Failed to fetch' },
{ status: 500 }
);
})
);
render(<TenantSwitcher />);
await waitFor(() => {
expect(screen.getByText(/載入失敗/i)).toBeInTheDocument();
});
});
});
// src/pages/Login.test.tsx
import { describe, test, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { Login } from './Login';
describe('Login Page', () => {
test('應該渲染登入表單', () => {
render(<Login />);
expect(screen.getByLabelText(/電子郵件/i)).toBeInTheDocument();
expect(screen.getByLabelText(/密碼/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /登入/i })).toBeInTheDocument();
});
test('應該驗證表單輸入', async () => {
const user = userEvent.setup();
render(<Login />);
const submitButton = screen.getByRole('button', { name: /登入/i });
// 不填寫任何欄位直接提交
await user.click(submitButton);
// 應該顯示錯誤訊息
await waitFor(() => {
expect(screen.getByText(/請輸入電子郵件/i)).toBeInTheDocument();
expect(screen.getByText(/請輸入密碼/i)).toBeInTheDocument();
});
});
test('應該可以成功登入', async () => {
const user = userEvent.setup();
const mockNavigate = vi.fn();
// Mock useNavigate
vi.mock('react-router-dom', async () => ({
...await vi.importActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
render(<Login />);
// 填寫表單
await user.type(screen.getByLabelText(/電子郵件/i), 'test@example.com');
await user.type(screen.getByLabelText(/密碼/i), 'password123');
// 提交
await user.click(screen.getByRole('button', { name: /登入/i }));
// 等待 API 回應
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
});
});
test('應該處理登入錯誤', async () => {
const user = userEvent.setup();
render(<Login />);
// 填寫錯誤的登入資訊
await user.type(screen.getByLabelText(/電子郵件/i), 'wrong@example.com');
await user.type(screen.getByLabelText(/密碼/i), 'wrongpassword');
await user.click(screen.getByRole('button', { name: /登入/i }));
// 應該顯示錯誤訊息
await waitFor(() => {
expect(screen.getByText(/登入失敗/i)).toBeInTheDocument();
});
});
test('應該顯示/隱藏密碼', async () => {
const user = userEvent.setup();
render(<Login />);
const passwordInput = screen.getByLabelText(/密碼/i);
const toggleButton = screen.getByRole('button', { name: /顯示密碼/i });
// 初始應該是 password type
expect(passwordInput).toHaveAttribute('type', 'password');
// 點擊顯示
await user.click(toggleButton);
expect(passwordInput).toHaveAttribute('type', 'text');
// 再次點擊隱藏
await user.click(toggleButton);
expect(passwordInput).toHaveAttribute('type', 'password');
});
});
// src/hooks/useAuth.test.ts
import { describe, test, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuth } from './useAuth';
// 建立測試用 wrapper
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('useAuth Hook', () => {
test('應該返回初始狀態', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
test('應該可以登入', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
// 執行登入
await result.current.login({
email: 'test@example.com',
password: 'password123',
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user).toEqual({
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
});
});
});
test('應該可以登出', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
// 先登入
await result.current.login({
email: 'test@example.com',
password: 'password123',
});
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
// 登出
result.current.logout();
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
});
});
// src/stores/authStore.test.ts
import { describe, test, expect, beforeEach } from 'vitest';
import { useAuthStore } from './authStore';
describe('Auth Store', () => {
beforeEach(() => {
// 每個測試前重置 store
useAuthStore.setState({
token: null,
user: null,
isAuthenticated: false,
});
});
test('應該設定登入狀態', () => {
const { setAuth } = useAuthStore.getState();
setAuth({
token: 'mock-token',
user: {
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
},
});
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.token).toBe('mock-token');
expect(state.user).toEqual({
id: 'user-1',
email: 'test@example.com',
name: 'Test User',
});
});
test('應該清除登入狀態', () => {
const { setAuth, clearAuth } = useAuthStore.getState();
// 先設定登入
setAuth({
token: 'mock-token',
user: { id: 'user-1', email: 'test@example.com', name: 'Test' },
});
// 清除
clearAuth();
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.token).toBeNull();
expect(state.user).toBeNull();
});
});
# 執行所有測試
pnpm test
# Watch 模式(開發時使用)
pnpm test
# UI 模式
pnpm test:ui
# 產生覆蓋率報告
pnpm test:coverage
# 執行單一檔案
pnpm test Login.test.tsx
$ pnpm test
✓ src/components/TenantSwitcher.test.tsx (4)
✓ TenantSwitcher (4)
✓ 應該顯示租戶列表
✓ 應該可以切換租戶
✓ 應該在載入時顯示 loading 狀態
✓ 應該處理錯誤狀態
✓ src/pages/Login.test.tsx (5)
✓ Login Page (5)
✓ 應該渲染登入表單
✓ 應該驗證表單輸入
✓ 應該可以成功登入
✓ 應該處理登入錯誤
✓ 應該顯示/隱藏密碼
✓ src/hooks/useAuth.test.ts (3)
✓ useAuth Hook (3)
✓ 應該返回初始狀態
✓ 應該可以登入
✓ 應該可以登出
✓ src/stores/authStore.test.ts (2)
✓ Auth Store (2)
✓ 應該設定登入狀態
✓ 應該清除登入狀態
Test Files 4 passed (4)
Tests 14 passed (14)
Start at 14:32:15
Duration 2.84s (transform 245ms, setup 0ms, collect 1.12s, tests 1.21s)
% Coverage report from v8
────────────────────────────────────────────────────
File % Stmts % Branch % Funcs % Lines
────────────────────────────────────────────────────
All files 85.2 82.1 84.3 85.8
components/ 87.5 85.0 88.0 88.2
TenantSwitcher.tsx 87.5 85.0 88.0 88.2
pages/ 84.2 80.5 82.1 84.8
Login.tsx 84.2 80.5 82.1 84.8
hooks/ 86.0 83.0 85.5 86.5
useAuth.ts 86.0 83.0 85.5 86.5
stores/ 90.0 88.0 92.0 90.5
authStore.ts 90.0 88.0 92.0 90.5
────────────────────────────────────────────────────
// ❌ 不好的做法:測試內部狀態
test('should set loading to true', () => {
const { result } = renderHook(() => useData());
expect(result.current.loading).toBe(true);
});
// ✅ 好的做法:測試使用者看到的內容
test('should show loading indicator', () => {
render(<DataComponent />);
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
// ❌ 不好
test('test1', () => { ... });
// ✅ 好
test('應該在表單驗證失敗時顯示錯誤訊息', () => { ... });
// ❌ 不好:依賴元件內部結構
expect(container.querySelector('.button-class')).toBeInTheDocument();
// ✅ 好:使用語意化查詢
expect(screen.getByRole('button', { name: '提交' })).toBeInTheDocument();
我們建立了完整的前端測試框架:
✅ 測試環境
✅ 元件測試
✅ Hooks 測試
✅ Store 測試
✅ 測試覆蓋率
明天我們將: