5月27日 19:54

如何在 Jest 中测试 React Hooks?renderHook 和 act 怎么用?

测试 React Hooks 的核心工具是 renderHookact。React 18 之后,renderHook 已从废弃的 @testing-library/react-hooks 迁移到 @testing-library/react,用法也有变化。

核心思路

  • renderHook:在测试环境中渲染 Hook,返回 result(当前返回值)、rerender(重新渲染)、unmount(卸载)
  • act:包裹所有会导致状态更新的操作,确保 React 完成渲染后再执行断言
  • waitFor:处理异步状态更新,替代旧版的 waitForNextUpdate

安装依赖

bash
npm install --save-dev jest @testing-library/react @testing-library/jest-dom

注意:@testing-library/react-hooks 已废弃,React 18+ 请统一使用 @testing-library/react

测试 useState

javascript
import { 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

javascript
import { 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

javascript
import { 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

javascript
import { 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 等待异步更新,不要在 actawait waitFor(那是反模式)
  • 异步 Hook 需要处理竞态:组件卸载后不应再 setState,用 cancelled 标志位或 AbortController

测试自定义 Hook

javascript
function 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)。

解决

  • 异步操作用 waitForawait act(async () => ...)
  • 定时器用 jest.useFakeTimers() 并在 act 中推进时间
  • 确保所有 setState 调用都在 act

"Can't perform a React state update on an unmounted component"

原因:异步操作完成后组件已卸载,仍然调用了 setState

解决:在 useEffect 清理函数中取消异步操作(cancelled 标志位 / AbortController)。

最佳实践

  1. @testing-library/reactrenderHook,不要再用废弃的 @testing-library/react-hooks
  2. 所有状态更新包裹 act,同步用 act(fn),异步用 await act(async fn)waitFor
  3. 测试行为不测实现:关注 Hook 的输入输出,不关注内部状态变量名
  4. 测试边界:初始值、空值、错误状态、并发场景
  5. rerender 测试依赖变化,用 unmount 测试清理逻辑
  6. Mock 外部依赖(API、定时器、DOM API),不 Mock React 内置 Hook
标签:JestReact Hook