5月27日 19:54
如何在 Jest 中测试 React Hooks?renderHook 和 act 怎么用?
测试 React Hooks 的核心工具是 renderHook 和 act。React 18 之后,renderHook 已从废弃的 @testing-library/react-hooks 迁移到 @testing-library/react,用法也有变化。
核心思路
- renderHook:在测试环境中渲染 Hook,返回
result(当前返回值)、rerender(重新渲染)、unmount(卸载) - act:包裹所有会导致状态更新的操作,确保 React 完成渲染后再执行断言
- waitFor:处理异步状态更新,替代旧版的
waitForNextUpdate
安装依赖
bashnpm install --save-dev jest @testing-library/react @testing-library/jest-dom
注意:
@testing-library/react-hooks已废弃,React 18+ 请统一使用@testing-library/react。
测试 useState
javascriptimport { renderHook, act } from '@testing-library/react'; function useCounter(initialValue = 0) { const [count, setCount] = useState(initialValue); const increment = () => setCount(c => c + 1); const decrement = () => setCount(c => c - 1); return { count, increment, decrement }; } test('useCounter 初始值和更新', () => { const { result } = renderHook(() => useCounter(0)); // 验证初始状态 expect(result.current.count).toBe(0); // 用 act 包裹状态更新 act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); });
关键点:任何触发 setState 的调用都必须包裹在 act() 中,否则 React 会发出警告,断言也可能基于未更新的状态。
测试 useEffect
javascriptimport { renderHook, act } from '@testing-library/react'; function useDocumentTitle(title) { useEffect(() => { document.title = title; return () => { document.title = 'default'; }; }, [title]); } test('useEffect 设置和清理', () => { const { result, unmount, rerender } = renderHook( ({ title }) => useDocumentTitle(title), { initialProps: { title: 'Hello' } } ); expect(document.title).toBe('Hello'); // 依赖变化时 effect 重新执行 rerender({ title: 'World' }); expect(document.title).toBe('World'); // 卸载时执行清理函数 unmount(); expect(document.title).toBe('default'); });
关键点:用 rerender 测试依赖变化,用 unmount 测试清理逻辑。
测试 useContext
javascriptimport { renderHook } from '@testing-library/react'; const ThemeContext = createContext('light'); function useTheme() { return useContext(ThemeContext); } test('useContext 读取 Provider 值', () => { const wrapper = ({ children }) => ( <ThemeContext.Provider value="dark"> {children} </ThemeContext.Provider> ); const { result } = renderHook(() => useTheme(), { wrapper }); expect(result.current).toBe('dark'); });
关键点:Hook 依赖 Context 时,通过 wrapper 选项注入 Provider,renderHook 会自动用 wrapper 包裹组件树。
测试异步 Hook
javascriptimport { renderHook, waitFor, act } from '@testing-library/react'; function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url) .then(res => res.json()) .then(json => { if (!cancelled) { setData(json); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err); setLoading(false); } }); return () => { cancelled = true; }; }, [url]); return { data, loading, error }; } test('useFetch 异步请求', async () => { // 用 jest.fn mock fetch global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ name: 'test' }) }) ); const { result } = renderHook(() => useFetch('/api/data')); // 初始状态 expect(result.current.loading).toBe(true); // 等待异步完成 await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(result.current.data).toEqual({ name: 'test' }); expect(result.current.error).toBeNull(); });
关键点:
- 用
waitFor等待异步更新,不要在act里await waitFor(那是反模式) - 异步 Hook 需要处理竞态:组件卸载后不应再
setState,用cancelled标志位或AbortController
测试自定义 Hook
javascriptfunction useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } test('useDebounce 防抖', () => { jest.useFakeTimers(); const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 500), { initialProps: { value: 'hello' } } ); expect(result.current).toBe('hello'); // 快速更新值,防抖未到期 rerender({ value: 'world' }); expect(result.current).toBe('hello'); // 还是旧值 // 快进 500ms act(() => { jest.advanceTimersByTime(500); }); expect(result.current).toBe('world'); jest.useRealTimers(); });
关键点:涉及定时器的 Hook,用 jest.useFakeTimers() + act(() => jest.advanceTimersByTime(ms)) 精确控制时间。
常见报错排查
"not wrapped in act()" 警告
原因:状态更新发生在 act() 之外(如异步回调、定时器未用 fake timers)。
解决:
- 异步操作用
waitFor或await act(async () => ...) - 定时器用
jest.useFakeTimers()并在act中推进时间 - 确保所有
setState调用都在act内
"Can't perform a React state update on an unmounted component"
原因:异步操作完成后组件已卸载,仍然调用了 setState。
解决:在 useEffect 清理函数中取消异步操作(cancelled 标志位 / AbortController)。
最佳实践
- 用
@testing-library/react的renderHook,不要再用废弃的@testing-library/react-hooks - 所有状态更新包裹
act,同步用act(fn),异步用await act(async fn)或waitFor - 测试行为不测实现:关注 Hook 的输入输出,不关注内部状态变量名
- 测试边界:初始值、空值、错误状态、并发场景
- 用
rerender测试依赖变化,用unmount测试清理逻辑 - Mock 外部依赖(API、定时器、DOM API),不 Mock React 内置 Hook