第一部分:React 測試

21.1 測試工具

工具 用途
Jest JavaScript 測試框架(執行器、斷言)
React Testing Library (RTL) 以使用者角度測試 React 元件
Vitest Vite 原生的測試框架(與 Jest API 相容)

安裝(Vite 專案使用 Vitest)

npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/setupTests.js',
  },
});
// src/setupTests.js
import '@testing-library/jest-dom';

21.2 測試基礎

第一個測試

// src/components/Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

export default Greeting;
// src/components/Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';

describe('Greeting', () => {
  test('renders greeting with name', () => {
    render(<Greeting name="Alice" />);
    expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
  });

  test('renders with different name', () => {
    render(<Greeting name="Bob" />);
    expect(screen.getByText('Hello, Bob!')).toBeInTheDocument();
  });
});

常用查詢方法

方法 說明 找不到時
getByText 依文字內容找 拋出錯誤
getByRole 依 ARIA role 找 拋出錯誤
getByLabelText 依 label 找 拋出錯誤
getByPlaceholderText 依 placeholder 找 拋出錯誤
getByTestId 依 data-testid 找 拋出錯誤
queryByText 依文字找 回傳 null
findByText 依文字找(async) 回傳 Promise

21.3 測試使用者互動

// src/components/Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p data-testid="count">Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

export default Counter;
// src/components/Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  test('starts at 0', () => {
    render(<Counter />);
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
  });

  test('increments on button click', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('Increment'));
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');

    await user.click(screen.getByText('Increment'));
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 2');
  });

  test('decrements on button click', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('Decrement'));
    expect(screen.getByTestId('count')).toHaveTextContent('Count: -1');
  });

  test('resets to 0', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByText('Increment'));
    await user.click(screen.getByText('Increment'));
    await user.click(screen.getByText('Reset'));

    expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
  });
});

21.4 測試表單

// src/components/LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm', () => {
  test('submits form with user input', async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn(); // Vitest 的 mock 函式
    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'alice@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: /登入/i }));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'alice@example.com',
      password: 'password123',
    });
  });

  test('shows validation errors', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.click(screen.getByRole('button', { name: /登入/i }));

    expect(screen.getByText(/email 為必填/i)).toBeInTheDocument();
    expect(screen.getByText(/密碼為必填/i)).toBeInTheDocument();
  });

  test('disables submit button when form is invalid', () => {
    render(<LoginForm onSubmit={vi.fn()} />);
    expect(screen.getByRole('button', { name: /登入/i })).toBeDisabled();
  });
});

21.5 測試非同步操作

// src/components/UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

// Mock fetch
global.fetch = vi.fn();

describe('UserProfile', () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  test('shows loading state', () => {
    fetch.mockImplementation(() => new Promise(() => {}));
    render(<UserProfile userId={1} />);
    expect(screen.getByText('載入中...')).toBeInTheDocument();
  });

  test('shows user data after loading', async () => {
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: 1, name: 'Alice', email: 'alice@example.com' }),
    });

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('alice@example.com')).toBeInTheDocument();
    });
  });

  test('shows error on failure', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'));

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText(/錯誤/)).toBeInTheDocument();
    });
  });
});

21.6 測試 Custom Hooks

import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';

describe('useCounter', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('increments', () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  test('decrements', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.decrement());
    expect(result.current.count).toBe(4);
  });

  test('resets', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.increment());
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});

第二部分:React 最佳實踐

21.7 元件設計原則

1. 單一職責

// ❌ 一個元件做太多事
function UserPage() {
  // 取資料 + 顯示列表 + 搜尋 + 分頁 + 表單 = 太複雜
}

// ✅ 拆分成小元件
function UserPage() {
  return (
    <>
      <SearchBar />
      <UserList />
      <Pagination />
    </>
  );
}

2. 容器/展示分離

// 展示元件:只負責渲染 UI
const UserCard = ({ name, email, avatar }) => (
  <div className="user-card">
    <img src={avatar} alt={name} />
    <h3>{name}</h3>
    <p>{email}</p>
  </div>
);

// 容器元件:負責資料和邏輯
function UserCardContainer({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return <Skeleton />;
  return <UserCard {...user} />;
}

3. Props 解構 + 預設值

// ✅ 清晰的 props 介面
const Button = ({
  children,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  onClick,
  ...rest
}) => {
  return (
    <button
      disabled={disabled}
      onClick={onClick}
      className={`btn btn-${variant} btn-${size}`}
      {...rest}
    >
      {children}
    </button>
  );
};

21.8 State 管理最佳實踐

1. 將 State 放在最近的共同父元件

// ❌ 把所有 state 都放在最上層
function App() {
  const [searchQuery, setSearchQuery] = useState('');     // 只有 SearchBar 用
  const [modalOpen, setModalOpen] = useState(false);       // 只有 Modal 用
  const [userName, setUserName] = useState('');             // 到處都用
  // ...
}

// ✅ State 就近管理
function App() {
  const [userName, setUserName] = useState('');  // 全域需要的
  return (
    <>
      <SearchSection />   {/* searchQuery 在這裡管理 */}
      <MainContent />     {/* modalOpen 在這裡管理 */}
    </>
  );
}

2. 派生值不要用 State

// ❌ 多餘的 state
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [itemCount, setItemCount] = useState(0);

// 每次 items 改變,都要手動同步其他 state

// ✅ 使用派生值
const [items, setItems] = useState([]);
const [filter, setFilter] = useState('');

const filteredItems = items.filter((item) =>
  item.name.includes(filter)
);
const itemCount = filteredItems.length;

3. State 選擇指南

需要觸發 UI 更新嗎?
├── 是 → useState / useReducer
└── 否 → useRef

跨越多層元件嗎?
├── 2-3 層 → Props 傳遞
├── 多層 → useContext
└── 全域 + 複雜邏輯 → Redux Toolkit

邏輯複雜嗎?
├── 簡單(1-2 個值)→ useState
└── 複雜(多個相關值、多種操作)→ useReducer

21.9 效能最佳實踐

1. 避免在 JSX 中建立新物件

// ❌ 每次渲染都建立新物件
<div style={{ color: 'red', fontSize: '14px' }}>Hello</div>

// ✅ 提取到元件外部(靜態樣式)
const textStyle = { color: 'red', fontSize: '14px' };
function Component() {
  return <div style={textStyle}>Hello</div>;
}

2. 善用 key

// ❌ 用 index 作為 key(列表可能改變時)
{items.map((item, i) => <Item key={i} {...item} />)}

// ✅ 用唯一 ID
{items.map((item) => <Item key={item.id} {...item} />)}

3. 避免過早優化

1. 先寫出正確的程式碼
2. 用 Profiler 量測效能瓶頸
3. 只在需要的地方優化

21.10 命名規範

類型 規範 範例
元件 PascalCase UserProfile, NavBar
自訂 Hook camelCase + use 開頭 useAuth, useFetch
事件處理 handle 開頭 handleClick, handleSubmit
回呼 Prop on 開頭 onClick, onSubmit
布林 Prop is/has/should 開頭 isLoading, hasError
Context XxxContext ThemeContext, AuthContext
檔案名稱 同元件名 UserProfile.jsx

21.11 專案結構推薦

src/
├── assets/              # 靜態資源(圖片、字體)
├── components/          # 共用元件
│   ├── ui/              # 基礎 UI 元件
│   │   ├── Button.jsx
│   │   ├── Input.jsx
│   │   └── Modal.jsx
│   └── layout/          # 佈局元件
│       ├── Header.jsx
│       └── Footer.jsx
├── features/            # 功能模組
│   ├── auth/
│   │   ├── components/  # 功能專屬元件
│   │   ├── hooks/       # 功能專屬 Hook
│   │   ├── services/    # API 呼叫
│   │   └── store/       # State 管理(slice)
│   └── posts/
│       ├── components/
│       ├── hooks/
│       └── services/
├── hooks/               # 全域共用 Hook
├── contexts/            # Context 定義
├── services/            # API 層
├── utils/               # 工具函式
├── pages/               # 頁面元件(搭配 Router)
├── App.jsx
└── main.jsx

21.12 檢查清單

開發檢查清單

  • [ ] 元件是否遵守單一職責?
  • [ ] State 是否放在最近的共同父元件?
  • [ ] 是否有可以提取的自訂 Hook?
  • [ ] key 是否使用了唯一且穩定的值?
  • [ ] useEffect 的依賴陣列是否完整?
  • [ ] useEffect 是否有必要的清除函式?
  • [ ] 是否避免了在渲染中直接產生副作用?
  • [ ] 錯誤處理是否完善?
  • [ ] 是否有適當的載入狀態?

上線前檢查清單

  • [ ] 移除所有 console.log
  • [ ] 移除未使用的 import 和變數
  • [ ] 確認沒有敏感資訊(API key、密碼)
  • [ ] 檢查無障礙(a11y):alt 文字、ARIA 標籤
  • [ ] 測試不同螢幕尺寸
  • [ ] 檢查瀏覽器相容性
  • [ ] 執行生產環境建構 npm run build
  • [ ] 檢查 bundle 大小

本課重點回顧

  1. 使用 React Testing Library 以使用者角度測試
  2. 測試重點:行為而非實作細節
  3. 元件設計遵守單一職責
  4. 派生值不要用額外的 State
  5. 先寫正確程式,再量測效能,最後優化
  6. 統一的命名規範專案結構