第一部分: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 大小
本課重點回顧
- 使用 React Testing Library 以使用者角度測試
- 測試重點:行為而非實作細節
- 元件設計遵守單一職責
- 派生值不要用額外的 State
- 先寫正確程式,再量測效能,最後優化
- 統一的命名規範和專案結構