React Hooks 测试 #

概述 #

测试自定义 Hooks 是 React 测试的重要组成部分。Testing Library 提供了专门的方法来测试 Hooks:

text
┌─────────────────────────────────────────────────────────────┐
│                     Hooks 测试方法                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  方法一:测试组件                                            │
│  ├── 创建使用 Hook 的测试组件                                │
│  ├── 通过组件 UI 验证 Hook 行为                              │
│  └── 最接近真实使用场景                                      │
│                                                             │
│  方法二:renderHook                                          │
│  ├── 直接测试 Hook 逻辑                                      │
│  ├── 不需要创建测试组件                                      │
│  └── 更适合单元测试                                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

方法一:测试组件 #

基本 Hook 测试 #

jsx
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);
  
  return { count, increment, decrement, reset };
}

// 创建测试组件
function Counter({ initialValue }) {
  const { count, increment, decrement, reset } = useCounter(initialValue);
  
  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

test('useCounter with test component', async () => {
  const user = userEvent.setup();
  render(<Counter initialValue={5} />);
  
  expect(screen.getByTestId('count')).toHaveTextContent('5');
  
  await user.click(screen.getByText('+'));
  expect(screen.getByTestId('count')).toHaveTextContent('6');
  
  await user.click(screen.getByText('-'));
  expect(screen.getByTestId('count')).toHaveTextContent('5');
  
  await user.click(screen.getByText('Reset'));
  expect(screen.getByTestId('count')).toHaveTextContent('5');
});

测试复杂 Hook #

jsx
function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = useCallback(() => setValue(v => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  
  return { value, toggle, setTrue, setFalse };
}

function ToggleComponent({ initialValue }) {
  const { value, toggle, setTrue, setFalse } = useToggle(initialValue);
  
  return (
    <div>
      <span data-testid="status">{value ? 'ON' : 'OFF'}</span>
      <button onClick={toggle}>Toggle</button>
      <button onClick={setTrue}>On</button>
      <button onClick={setFalse}>Off</button>
    </div>
  );
}

test('useToggle with test component', async () => {
  const user = userEvent.setup();
  render(<ToggleComponent />);
  
  expect(screen.getByTestId('status')).toHaveTextContent('OFF');
  
  await user.click(screen.getByText('Toggle'));
  expect(screen.getByTestId('status')).toHaveTextContent('ON');
  
  await user.click(screen.getByText('Off'));
  expect(screen.getByTestId('status')).toHaveTextContent('OFF');
  
  await user.click(screen.getByText('On'));
  expect(screen.getByTestId('status')).toHaveTextContent('ON');
});

方法二:renderHook #

基本用法 #

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

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

renderHook 返回值 #

jsx
test('renderHook return values', () => {
  const { result, rerender, unmount } = renderHook(
    ({ initialValue }) => useCounter(initialValue),
    { initialProps: { initialValue: 0 } }
  );
  
  // result.current - Hook 返回值
  expect(result.current.count).toBe(0);
  
  // rerender - 重新渲染
  rerender({ initialValue: 10 });
  
  // unmount - 卸载
  unmount();
});

测试带参数的 Hook #

jsx
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
  
  return { data, loading, error };
}

test('useFetch with different URLs', async () => {
  const { result, rerender } = renderHook(
    ({ url }) => useFetch(url),
    { initialProps: { url: '/api/users' } }
  );
  
  expect(result.current.loading).toBe(true);
  
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });
  
  expect(result.current.data).toBeDefined();
  
  // 更改 URL
  rerender({ url: '/api/posts' });
  
  expect(result.current.loading).toBe(true);
});

测试 useState Hook #

状态变化测试 #

jsx
function useFormInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);
  
  const onChange = useCallback((e) => {
    setValue(e.target.value);
  }, []);
  
  const reset = useCallback(() => {
    setValue(initialValue);
  }, [initialValue]);
  
  return { value, onChange, reset };
}

test('useFormInput state changes', () => {
  const { result } = renderHook(() => useFormInput('initial'));
  
  expect(result.current.value).toBe('initial');
  
  act(() => {
    result.current.onChange({ target: { value: 'new value' } });
  });
  
  expect(result.current.value).toBe('new value');
  
  act(() => {
    result.current.reset();
  });
  
  expect(result.current.value).toBe('initial');
});

多状态测试 #

jsx
function useMultiState() {
  const [state, setState] = useState({
    name: '',
    email: '',
    age: 0,
  });
  
  const updateField = useCallback((field, value) => {
    setState((prev) => ({ ...prev, [field]: value }));
  }, []);
  
  return { state, updateField };
}

test('useMultiState', () => {
  const { result } = renderHook(() => useMultiState());
  
  expect(result.current.state).toEqual({
    name: '',
    email: '',
    age: 0,
  });
  
  act(() => {
    result.current.updateField('name', 'John');
    result.current.updateField('email', 'john@example.com');
    result.current.updateField('age', 25);
  });
  
  expect(result.current.state).toEqual({
    name: 'John',
    email: 'john@example.com',
    age: 25,
  });
});

测试 useEffect Hook #

副作用测试 #

jsx
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

test('useDocumentTitle', () => {
  renderHook(() => useDocumentTitle('Test Title'));
  
  expect(document.title).toBe('Test Title');
});

清理函数测试 #

jsx
function useEventListener(eventName, handler) {
  useEffect(() => {
    window.addEventListener(eventName, handler);
    return () => window.removeEventListener(eventName, handler);
  }, [eventName, handler]);
}

test('useEventListener cleanup', () => {
  const handler = jest.fn();
  const { unmount } = renderHook(() => useEventListener('resize', handler));
  
  expect(window.addEventListener).toHaveBeenCalledWith('resize', handler);
  
  unmount();
  
  expect(window.removeEventListener).toHaveBeenCalledWith('resize', handler);
});

依赖变化测试 #

jsx
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    };
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return size;
}

test('useWindowSize', () => {
  const { result } = renderHook(() => useWindowSize());
  
  expect(result.current.width).toBe(window.innerWidth);
  expect(result.current.height).toBe(window.innerHeight);
});

测试 useCallback 和 useMemo #

useCallback 测试 #

jsx
function useStableCallback(callback) {
  const callbackRef = useRef(callback);
  
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  
  return useCallback((...args) => {
    return callbackRef.current(...args);
  }, []);
}

test('useStableCallback maintains reference', () => {
  const callback1 = jest.fn();
  const callback2 = jest.fn();
  
  const { result, rerender } = renderHook(
    ({ callback }) => useStableCallback(callback),
    { initialProps: { callback: callback1 } }
  );
  
  const firstCallback = result.current;
  
  rerender({ callback: callback2 });
  
  expect(result.current).toBe(firstCallback);
  
  act(() => {
    result.current('test');
  });
  
  expect(callback2).toHaveBeenCalledWith('test');
});

useMemo 测试 #

jsx
function useExpensiveComputation(value) {
  const computed = useMemo(() => {
    return value * 2;
  }, [value]);
  
  return computed;
}

test('useExpensiveComputation memoizes', () => {
  const { result, rerender } = renderHook(
    ({ value }) => useExpensiveComputation(value),
    { initialProps: { value: 5 } }
  );
  
  expect(result.current).toBe(10);
  
  // 相同值,不重新计算
  rerender({ value: 5 });
  expect(result.current).toBe(10);
  
  // 不同值,重新计算
  rerender({ value: 10 });
  expect(result.current).toBe(20);
});

测试 useRef Hook #

jsx
function usePrevious(value) {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
}

test('usePrevious', () => {
  const { result, rerender } = renderHook(
    ({ value }) => usePrevious(value),
    { initialProps: { value: 'first' } }
  );
  
  expect(result.current).toBeUndefined();
  
  rerender({ value: 'second' });
  expect(result.current).toBe('first');
  
  rerender({ value: 'third' });
  expect(result.current).toBe('second');
});

测试 useReducer Hook #

jsx
function useCounterReducer(initialValue = 0) {
  const [state, dispatch] = useReducer((state, action) => {
    switch (action.type) {
      case 'increment':
        return { count: state.count + 1 };
      case 'decrement':
        return { count: state.count - 1 };
      case 'reset':
        return { count: action.payload };
      default:
        return state;
    }
  }, { count: initialValue });
  
  return { state, dispatch };
}

test('useCounterReducer', () => {
  const { result } = renderHook(() => useCounterReducer(5));
  
  expect(result.current.state.count).toBe(5);
  
  act(() => {
    result.current.dispatch({ type: 'increment' });
  });
  
  expect(result.current.state.count).toBe(6);
  
  act(() => {
    result.current.dispatch({ type: 'decrement' });
  });
  
  expect(result.current.state.count).toBe(5);
  
  act(() => {
    result.current.dispatch({ type: 'reset', payload: 0 });
  });
  
  expect(result.current.state.count).toBe(0);
});

测试自定义 Hook #

useLocalStorage #

jsx
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });
  
  const setValue = useCallback((value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  }, [key, storedValue]);
  
  return [storedValue, setValue];
}

test('useLocalStorage', () => {
  const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
  
  expect(result.current[0]).toBe('initial');
  
  act(() => {
    result.current[1]('new value');
  });
  
  expect(result.current[0]).toBe('new value');
  expect(localStorage.getItem('test-key')).toBe('"new value"');
});

useDebounce #

jsx
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, delay }) => useDebounce(value, delay),
    { initialProps: { value: 'initial', delay: 500 } }
  );
  
  expect(result.current).toBe('initial');
  
  rerender({ value: 'changed', delay: 500 });
  
  // 还未更新
  expect(result.current).toBe('initial');
  
  // 快进时间
  act(() => {
    jest.advanceTimersByTime(500);
  });
  
  expect(result.current).toBe('changed');
  
  jest.useRealTimers();
});

useFetch #

jsx
function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
  });
  
  useEffect(() => {
    let cancelled = false;
    
    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) {
          setState({ data, loading: false, error: null });
        }
      })
      .catch((error) => {
        if (!cancelled) {
          setState({ data: null, loading: false, error });
        }
      });
    
    return () => {
      cancelled = true;
    };
  }, [url]);
  
  return state;
}

test('useFetch', async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      json: () => Promise.resolve({ name: 'John' }),
    })
  );
  
  const { result } = renderHook(() => useFetch('/api/user'));
  
  expect(result.current.loading).toBe(true);
  
  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });
  
  expect(result.current.data).toEqual({ name: 'John' });
  expect(result.current.error).toBeNull();
});

测试 Hook 的错误处理 #

jsx
function useThrowError(shouldThrow) {
  if (shouldThrow) {
    throw new Error('Test error');
  }
  return 'ok';
}

test('useThrowError', () => {
  // 正常情况
  const { result, rerender } = renderHook(
    ({ shouldThrow }) => useThrowError(shouldThrow),
    { initialProps: { shouldThrow: false } }
  );
  
  expect(result.current).toBe('ok');
  
  // 抛出错误
  expect(() => {
    rerender({ shouldThrow: true });
  }).toThrow('Test error');
});

测试 Hook 的异步行为 #

jsx
function useAsyncAction() {
  const [status, setStatus] = useState('idle');
  const [data, setData] = useState(null);
  
  const execute = useCallback(async (promise) => {
    setStatus('loading');
    try {
      const result = await promise;
      setData(result);
      setStatus('success');
    } catch (error) {
      setStatus('error');
    }
  }, []);
  
  return { status, data, execute };
}

test('useAsyncAction', async () => {
  const { result } = renderHook(() => useAsyncAction());
  
  expect(result.current.status).toBe('idle');
  
  act(() => {
    result.current.execute(Promise.resolve('test data'));
  });
  
  expect(result.current.status).toBe('loading');
  
  await waitFor(() => {
    expect(result.current.status).toBe('success');
  });
  
  expect(result.current.data).toBe('test data');
});

最佳实践 #

推荐做法 #

jsx
// ✅ 使用 act 包装状态更新
act(() => {
  result.current.increment();
});

// ✅ 使用 waitFor 等待异步操作
await waitFor(() => {
  expect(result.current.loading).toBe(false);
});

// ✅ 测试组件更接近真实使用场景
function TestComponent() {
  const hook = useCustomHook();
  return <div>{hook.value}</div>;
}

// ✅ 测试清理函数
const { unmount } = renderHook(() => useHook());
unmount();

避免做法 #

jsx
// ❌ 直接调用 Hook(不在组件中)
useCounter(); // Error

// ❌ 忘记 act 包装
result.current.increment(); // 可能导致警告

// ❌ 不等待异步操作
expect(result.current.data).toBeDefined(); // 可能失败

// ❌ 忽略清理
// unmount() 缺失

下一步 #

现在你已经掌握了 React Hooks 测试,接下来学习 Context 测试 了解如何测试 Context Provider 和 Consumer!

最后更新:2026-03-28