iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0
Modern Web

30 天製作工作室 SaaS 產品 (前端篇)系列 第 21

Day 21: 30天打造SaaS產品前端篇 - React Testing Library 與元件測試策略

  • 分享至 

  • xImage
  •  

前情提要

在 Day 20 完成前端架構盤點後,我們發現測試覆蓋率還需要提升。今天我們將使用 Vitest + React Testing Library 建立完整的前端測試框架,確保 Kyo-Dashboard 的品質與可靠性。

為什麼選擇 Vitest?

Vitest 是專為 Vite 設計的測試框架,契合我們的技術棧:

Vitest vs Jest 對比

特性 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 環境

Vite 配置整合

// 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,
      },
    },
  },
});

Package.json Scripts

{
  "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 };

Mock Server Worker (MSW)

// 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');
  });
});

Hooks 測試

// 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();
    });
  });
});

Zustand Store 測試

// 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
────────────────────────────────────────────────────

測試最佳實踐

1. 測試使用者行為,不是實作細節

// ❌ 不好的做法:測試內部狀態
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();
});

2. 使用有意義的測試描述

// ❌ 不好
test('test1', () => { ... });

// ✅ 好
test('應該在表單驗證失敗時顯示錯誤訊息', () => { ... });

3. 避免測試實作細節

// ❌ 不好:依賴元件內部結構
expect(container.querySelector('.button-class')).toBeInTheDocument();

// ✅ 好:使用語意化查詢
expect(screen.getByRole('button', { name: '提交' })).toBeInTheDocument();

今日總結

我們建立了完整的前端測試框架:

完成項目

測試環境

  • Vitest + React Testing Library
  • MSW API mocking
  • 完整的 Provider wrapper

元件測試

  • 基礎元件測試
  • 表單驗證測試
  • 使用者互動測試

Hooks 測試

  • 自訂 hooks 測試
  • React Query integration

Store 測試

  • Zustand store 測試
  • 狀態管理驗證

測試覆蓋率

  • 85%+ 覆蓋率
  • 視覺化報告

下一步

明天我們將:

  • 建立 E2E 測試 (Playwright)
  • 視覺回歸測試
  • CI/CD 測試整合

參考資源


上一篇
Day 20: 30天打造SaaS產品前端篇 - 第20天React架構盤點與實踐驗證
系列文
30 天製作工作室 SaaS 產品 (前端篇)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言