測驗:測試自訂 Hooks:renderHook 與 act
共 5 題,點選答案後會立即顯示結果
1. 為什麼需要使用 renderHook 來測試自訂 Hook?
2. renderHook 回傳的物件中,result.current 代表什麼?
3. 為什麼需要使用 act 來包裹狀態更新操作?
4. 測試含有 useEffect cleanup 函式的 Hook 時,應該使用哪個方法來模擬元件卸載?
5. 測試使用 setTimeout 或 setInterval 的 Hook(如 useDebounce)時,應該搭配什麼技術?
一句話說明
用 renderHook 單獨測試 hook 邏輯,用 act 確保狀態更新完成再斷言。
為什麼需要 renderHook?
Hook 不能直接呼叫,必須在 React 元件裡面使用。但為了測試 hook 邏輯,每次都寫一個測試元件太麻煩。
// 不好:每次都要寫測試元件
function TestComponent() {
const result = useCounter();
return <div>{result.count}</div>;
}
// 好:用 renderHook 直接測試
const { result } = renderHook(() => useCounter());
Code language: JavaScript (javascript)一句話:renderHook 幫你建立一個隱形的測試元件,讓你專注在 hook 邏輯。
最小範例
待測試的 Hook
// useCounter.ts
import { useState } from 'react';
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
Code language: JavaScript (javascript)測試程式碼
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('初始值為 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('increment 增加計數', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Code language: JavaScript (javascript)逐行翻譯:renderHook
import { renderHook, act } from '@testing-library/react';
// ↑ 從 testing-library 引入 hook 測試工具
const { result } = renderHook(() => useCounter());
// ↑ 執行 hook,結果放在 result 裡
expect(result.current.count).toBe(0);
// ↑ result.current 是 hook 回傳的值
Code language: JavaScript (javascript)renderHook 回傳什麼?
const { result, rerender, unmount } = renderHook(() => useMyHook());
// result.current - hook 目前回傳的值
// rerender() - 重新渲染,用於測試依賴項變化
// unmount() - 卸載,用於測試 cleanup
Code language: JavaScript (javascript)| 回傳值 | 用途 |
|---|---|
result.current |
取得 hook 目前的回傳值 |
rerender() |
重新執行 hook(可傳新參數) |
unmount() |
模擬元件卸載 |
逐行翻譯:act
act(() => {
result.current.increment(); // 狀態更新操作
});
// ↑ act 確保 React 狀態更新完成後再往下執行
expect(result.current.count).toBe(1);
// ↑ 這時候狀態已經更新了
Code language: JavaScript (javascript)為什麼需要 act?
React 的狀態更新是非同步的。沒有 act,斷言可能在狀態更新前就執行了:
// 可能失敗:狀態還沒更新
result.current.increment();
expect(result.current.count).toBe(1); // 可能還是 0
// 正確:用 act 包起來
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1); // 確定是 1
Code language: JavaScript (javascript)一句話:任何會觸發狀態更新的操作,都要用 act 包起來。
常見變化
變化 1:測試初始參數
test('可以設定初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
Code language: JavaScript (javascript)翻譯:傳參數給 hook,測試初始狀態。
變化 2:用 rerender 測試參數變化
test('rerender 可以傳入新參數', () => {
const { result, rerender } = renderHook(
({ initial }) => useCounter(initial),
{ initialProps: { initial: 0 } }
);
expect(result.current.count).toBe(0);
rerender({ initial: 100 });
// 注意:這只會觸發重新渲染,不會重設 state
});
Code language: JavaScript (javascript)翻譯:rerender 模擬 props 變化,測試 hook 如何響應。
變化 3:多次狀態更新
test('多次操作', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(3);
});
Code language: JavaScript (javascript)翻譯:多個操作可以放在同一個 act 裡面。
測試含有 useEffect 的 Hook
待測試的 Hook
// useDocumentTitle.ts
import { useEffect } from 'react';
export function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}
Code language: JavaScript (javascript)測試程式碼
test('設定 document title', () => {
renderHook(() => useDocumentTitle('Hello'));
expect(document.title).toBe('Hello');
});
test('title 變化時更新', () => {
const { rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: 'Hello' } }
);
expect(document.title).toBe('Hello');
rerender({ title: 'World' });
expect(document.title).toBe('World');
});
Code language: JavaScript (javascript)重點:useEffect 在 renderHook 後會自動執行,不需要額外的 act。
測試含有 cleanup 的 Hook
待測試的 Hook
// useEventListener.ts
import { useEffect } from 'react';
export function useEventListener(
event: string,
handler: (e: Event) => void
) {
useEffect(() => {
window.addEventListener(event, handler);
return () => {
window.removeEventListener(event, handler); // cleanup
};
}, [event, handler]);
}
Code language: JavaScript (javascript)測試程式碼
test('卸載時移除事件監聽', () => {
const handler = vi.fn();
const { unmount } = renderHook(() =>
useEventListener('click', handler)
);
// 觸發事件,handler 會被呼叫
window.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledTimes(1);
// 卸載 hook
unmount();
// 再次觸發,handler 不會被呼叫
window.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalledTimes(1); // 還是 1
});
Code language: JavaScript (javascript)重點:用 unmount() 測試 cleanup 函式有沒有正確執行。
實戰:測試 useLocalStorage
待測試的 Hook
// useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
Code language: JavaScript (javascript)測試程式碼
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
test('初始值正確', () => {
const { result } = renderHook(() =>
useLocalStorage('name', 'default')
);
expect(result.current[0]).toBe('default');
});
test('讀取已存在的值', () => {
localStorage.setItem('name', '"stored"');
const { result } = renderHook(() =>
useLocalStorage('name', 'default')
);
expect(result.current[0]).toBe('stored');
});
test('更新值會同步到 localStorage', () => {
const { result } = renderHook(() =>
useLocalStorage('count', 0)
);
act(() => {
result.current[1](42); // setValue(42)
});
expect(result.current[0]).toBe(42);
expect(localStorage.getItem('count')).toBe('42');
});
});
Code language: JavaScript (javascript)實戰:測試 useDebounce
待測試的 Hook
// useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Code language: JavaScript (javascript)測試程式碼
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test('延遲更新值', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'initial' } }
);
// 初始值
expect(result.current).toBe('initial');
// 更新輸入值
rerender({ value: 'updated' });
// 還沒到 500ms,值不變
expect(result.current).toBe('initial');
// 快轉 500ms
act(() => {
vi.advanceTimersByTime(500);
});
// 現在值更新了
expect(result.current).toBe('updated');
});
test('連續輸入只保留最後一個', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
vi.advanceTimersByTime(100);
rerender({ value: 'abc' });
vi.advanceTimersByTime(100);
rerender({ value: 'abcd' });
// 還是初始值
expect(result.current).toBe('a');
// 從最後一次輸入開始算 500ms
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe('abcd');
});
});
Code language: JavaScript (javascript)重點:測試 debounce 需要搭配 fake timers。
測試非同步 Hook
待測試的 Hook
// useFetch.ts
import { useState, useEffect } from 'react';
export function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
Code language: JavaScript (javascript)測試程式碼
describe('useFetch', () => {
beforeEach(() => {
vi.resetAllMocks();
});
test('成功取得資料', async () => {
const mockData = { name: 'Alice' };
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve(mockData),
});
const { result } = renderHook(() =>
useFetch('/api/user')
);
// 初始狀態:loading
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
// 等待非同步完成
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
test('處理錯誤', async () => {
global.fetch = vi.fn().mockRejectedValue(
new Error('Network error')
);
const { result } = renderHook(() =>
useFetch('/api/user')
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error?.message).toBe('Network error');
});
});
Code language: JavaScript (javascript)重點:非同步 hook 用 waitFor 等待狀態變化。
常見錯誤與解決
錯誤 1:忘記用 act
// 錯誤:會有 warning
result.current.increment();
expect(result.current.count).toBe(1);
// 正確
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
Code language: JavaScript (javascript)錯誤 2:在 act 外面斷言更新後的值
// 錯誤:斷言放在 act 裡面
act(() => {
result.current.increment();
expect(result.current.count).toBe(1); // 可能還沒更新
});
// 正確:斷言放在 act 外面
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
Code language: JavaScript (javascript)錯誤 3:忘記 await 非同步的 act
// 錯誤:非同步操作沒有 await
act(() => {
result.current.fetchData(); // 非同步
});
// 正確:用 await
await act(async () => {
await result.current.fetchData();
});
Code language: JavaScript (javascript)整理:act 使用時機
| 情況 | 需要 act? |
|---|---|
| 呼叫會更新狀態的函式 | 是 |
| 觸發事件 | 是 |
| 快轉 fake timers | 是 |
| 單純讀取 result.current | 否 |
| renderHook 本身 | 否(內部已處理) |
Vibe Coder 檢查點
看到 hook 測試時確認:
- [ ] 狀態更新有用
act包起來嗎? - [ ] 斷言是放在
act外面嗎? - [ ] 非同步操作有用
waitFor或await act嗎? - [ ] 有測試 cleanup(unmount)嗎?
- [ ] 用到 timer 的 hook 有用 fake timers 嗎?
延伸:知道就好
這些進階用法遇到再查:
- wrapper:測試需要 Provider 的 hook(如 Context)
- initialProps:設定 hook 的初始參數
- hydrate:測試 SSR hydration
// wrapper 範例:提供 Context
const wrapper = ({ children }) => (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
Code language: JavaScript (javascript)重點回顧
- renderHook:獨立測試 hook,不需要寫測試元件
- result.current:取得 hook 目前的回傳值
- act:確保狀態更新完成後再斷言
- rerender:測試 props 變化
- unmount:測試 cleanup 邏輯
- waitFor:等待非同步操作完成
進階測驗:測試自訂 Hooks:renderHook 與 act
測驗目標:驗證你是否能在實際情境中應用所學。
共 5 題,包含情境題與錯誤診斷題。
共 5 題,包含情境題與錯誤診斷題。
1. 你正在測試一個 useCounter Hook,需要驗證連續呼叫三次 increment 後計數是否為 3。以下哪種寫法最正確? 情境題
2. 小明寫了以下測試程式碼,但測試失敗了:錯誤診斷
test(‘increment 增加計數’, () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
expect(result.current.count).toBe(1);
});
});
最可能的問題是什麼?
3. 你需要測試一個 useDebounce Hook,它會在 500ms 後才更新值。你已經設定了 vi.useFakeTimers(),接下來應該怎麼做? 情境題
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 500),
{ initialProps: { value: ‘initial’ } }
);
rerender({ value: ‘updated’ });
// 接下來應該怎麼寫?
4. 以下測試 useFetch Hook 的程式碼有什麼問題?錯誤診斷
test(‘成功取得資料’, () => {
const mockData = { name: ‘Alice’ };
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve(mockData),
});
const { result } = renderHook(() => useFetch(‘/api/user’));
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual(mockData);
});