自定义 Hooks 是 React 逻辑复用的核心手段,但它们无法脱离组件独立调用。为了对 Hooks 进行独立的单元测试,React 社区提供了专门的工具——renderHook。它允许你在一个隔离的测试环境中调用 Hook,反复触发更新并断言返回值,无需手动包裹在组件中。
测试工具与环境
单元测试通常使用 Jest 作为测试运行器,结合 React Testing Library 提供的 renderHook 和 act 进行 Hook 测试。
npm install --save-dev @testing-library/react @testing-library/jest-dom jest
renderHook 接收一个回调函数(在其中调用你的 Hook),并返回一个结果对象,包含:
result.current:Hook 当前的返回值rerender:用新参数重新执行 Hook,模拟组件重渲染unmount:卸载 Hook,触发清理逻辑
需要特别注意:涉及状态更新的操作必须包裹在 act() 中,以确保 React 能正确处理状态更新和副作用。
场景一:测试纯逻辑 Hook(无副作用)
这是最简单的场景,Hook 只依赖输入参数返回计算结果。
// hooks/usePagination.js
export function usePagination(totalItems, pageSize) {
const totalPages = Math.ceil(totalItems / pageSize);
return { totalPages };
}
// hooks/usePagination.test.js
import { renderHook } from '@testing-library/react';
import { usePagination } from './usePagination';
test('计算总页数正确', () => {
const { result } = renderHook(() => usePagination(100, 10));
expect(result.current.totalPages).toBe(10);
});
test('不足一页按一页算', () => {
const { result } = renderHook(() => usePagination(5, 10));
expect(result.current.totalPages).toBe(1);
});
场景二:测试包含 useState 的 Hook
当 Hook 内部使用 useState 并暴露更新函数时,需要在 act() 中调用这些更新函数,确保状态变动后的值能被正确读取。
// hooks/useCounter.js
import { useState } from 'react';
export 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 };
}
// hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('计数初始值及增减操作', () => {
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();
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
场景三:测试包含 useEffect 和清理逻辑
有副作用的 Hook(如定时器、事件监听、订阅)通常需要在测试中验证副作用是否被触发以及清理逻辑是否正确执行。使用 unmount 来模拟组件卸载,断言清理函数的行为。
// hooks/useDocumentTitle.js
import { useEffect } from 'react';
export function useDocumentTitle(title) {
useEffect(() => {
const originalTitle = document.title;
document.title = title;
return () => {
document.title = originalTitle;
};
}, [title]);
}
// hooks/useDocumentTitle.test.js
import { renderHook } from '@testing-library/react';
import { useDocumentTitle } from './useDocumentTitle';
test('设置文档标题并在卸载时还原', () => {
const originalTitle = '原始标题';
document.title = originalTitle;
const { rerender, unmount } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: '新页面' } }
);
expect(document.title).toBe('新页面');
rerender({ title: '更新标题' });
expect(document.title).toBe('更新标题');
unmount();
expect(document.title).toBe(originalTitle);
});
场景四:测试异步 Hook
许多 Hook 涉及异步数据请求(Promise、async/await)。可以使用 waitFor 或 waitForNextUpdate(React Testing Library 提供的工具)来等待异步状态完成。
// hooks/useFetch.js
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(json => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
测试时,需要模拟 fetch 保证测试的确定性和速度。可以用 jest.spyOn 或 msw 等库模拟网络请求。
// hooks/useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
test('成功获取数据', async () => {
const mockData = { id: 1, name: 'Test' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useFetch('/api/data'));
// 初始 loading 状态
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
// 等待异步结束
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBeNull();
});
test('网络出错时设置 error', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useFetch('/api/data'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBe('Network error');
expect(result.current.data).toBeNull();
});
场景五:测试依赖 Context 的 Hook
如果 Hook 内部使用了 useContext,测试时需要提供相应的 Provider 包裹。renderHook 的第二个参数支持传递一个 wrapper 组件。
// hooks/useTheme.js
import { useContext } from 'react';
import { ThemeContext } from '../context/ThemeContext';
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme 必须在 ThemeProvider 内使用');
}
return context;
}
// hooks/useTheme.test.js
import { renderHook } from '@testing-library/react';
import { ThemeContext } from '../context/ThemeContext';
import { useTheme } from './useTheme';
test('正确获取 context 值', () => {
const wrapper = ({ children }) => (
<ThemeContext.Provider value={{ theme: 'dark' }}>
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('dark');
});
test('Provider 缺失时抛出错误', () => {
// 不提供 wrapper,应抛出
// 使用 renderHook 时需要在 expect 中包裹函数调用
expect(() => renderHook(() => useTheme())).toThrow(
'useTheme 必须在 ThemeProvider 内使用'
);
});
Hooks 单元测试的最佳实践
- 测试行为而非实现细节
关注 Hook 对外暴露的返回值与副作用,而不是内部的变量名或具体调用路径。如果 refactor 内部逻辑但行为不变,测试不应失败。
- 隔离副作用
使用 jest.fn() 替换真实的 fetch、localStorage 或定时器,使测试快速、可靠。可用 jest.useFakeTimers() 控制时间相关的 Hook(如防抖节流)。
- 覆盖所有状态分支
包括加载中、成功、失败、空数据、边界值等,确保 Hook 对各种输入都能正常工作。
- 单独测试清理函数
通过 unmount() 手动卸载 Hook,验证 useEffect 的清理逻辑是否执行,避免内存泄漏隐患。
- 避免测试组件中内联的 Hook
只测试可以被独立引用的自定义 Hook(文件导出的函数)。不要为了测试而将组件内部的逻辑抽成 Hook,那样反而增加了复杂度。
通过系统地对自定义 Hooks 进行单元测试,可以在逻辑复用层就拦截绝大部分的 bug,让组件集成测试更加轻松和稳定。