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