如何使用Jest模拟计时器正确测试React轮询自定义Hook
问题原因
你的问题主要来自两个点:
- useCallback依赖写法不规范:
useRef的current值不能作为依赖项传入useCallback。useRef返回的对象在组件整个生命周期内都是稳定的,直接在回调函数里访问.current就能拿到最新值,不需要放到依赖数组里。原来的写法虽然不影响ref值读取,但是会违反React Hook的依赖规则,也会导致后续参数更新时回调无法正常刷新。 - 假计时器模式下没有手动清空微任务:Jest的
runAllTimers()是同步执行的,只会处理调用瞬间已经注册的计时器。你的轮询逻辑是等当前异步回调执行完之后才会注册下一次的setTimeout,如果不清空微任务队列,新的计时器不会被注册,下一次runAllTimers()自然也触发不了新的轮询。真实时钟测试能过是因为真实环境下微任务会自动执行,不需要手动干预。
修复方法
1. 修复usePolling的useCallback依赖
把依赖数组里的mountedRef.current和activeRef.current删掉即可:
import { useCallback, useEffect, useRef } from "react"; /** * @param asyncFunction * @param interval milliseconds between calls to the asyncFunction, defaults to a minute * @param immediate if true it will run the asyncFunction immediately before looping */ export function usePolling( asyncFunction: () => any | PromiseLike<any>, interval = 60000, immediate = true ) { const timeoutRef = useRef<ReturnType<typeof setTimeout>>(); const mountedRef = useRef(false); const activeRef = useRef(false); const wrappedAsyncFunction = useCallback(async () => { console.log("POLL", { mountedRef, activeRef }); if (!mountedRef.current || activeRef.current) { // don't process if currently active or the component is unmounted return; } activeRef.current = true; try { console.log("ABOUT TO ASYNC", { mountedRef, activeRef }); await asyncFunction(); console.log("ASYNC DONE", { mountedRef, activeRef }); } catch (e) { console.error("Error while polling", e); } console.log("ABOUT TO SET TIMEOUT"); timeoutRef.current = setTimeout(wrappedAsyncFunction, interval); console.log(" SET TIMEOUT", timeoutRef); activeRef.current = false; // 仅保留asyncFunction和interval作为依赖 }, [asyncFunction, interval]); useEffect(() => { mountedRef.current = true; if (immediate) { wrappedAsyncFunction(); } else { timeoutRef.current = setTimeout(wrappedAsyncFunction, interval); } return () => { clearTimeout(timeoutRef.current!); mountedRef.current = false; activeRef.current = false; }; // 如果需要支持参数动态更新,这里要加上[immediate, asyncFunction, interval]作为依赖 }, []); return { activeRef }; }
如果你需要支持轮询参数(interval、asyncFunction、immediate)动态更新,还要把这三个参数加到外层useEffect的依赖数组里,同时在清理函数中清除当前计时器,确保参数变更后轮询自动重启。
2. 调整测试用例逻辑
每次执行完runAllTimers()之后加一个await Promise.resolve()清空微任务队列,确保异步逻辑执行完成、下一次计时器已经注册,不需要用waitFor:
it("should work with just the callback", async () => { jest.useFakeTimers(); const callback = jest.fn(); let renderCount = 0; function MyComponent() { usePolling(callback); ++renderCount; return (<div data-testid="test">{renderCount}</div>); } const { getByTestId } = render(<MyComponent />); expect(getByTestId("test").textContent).toEqual("1"); expect(renderCount).toEqual(1); // 清空微任务,确保首次immediate调用执行完成 await Promise.resolve(); expect(callback).toBeCalledTimes(1); // 触发第一次轮询 jest.runAllTimers(); await Promise.resolve(); expect(callback).toBeCalledTimes(2); expect(renderCount).toEqual(1); // 触发第二次轮询 jest.runAllTimers(); await Promise.resolve(); expect(callback).toBeCalledTimes(3); expect(renderCount).toEqual(1); // 测试结束后重置为真实计时器,避免影响其他测试用例 jest.useRealTimers(); });
可选优化
如果要更精准控制轮询间隔,可以自定义一个计时器推进函数,避免runAllTimers()一次性执行所有计时器的不可控问题:
// 封装计时器推进逻辑,同时清空微任务 const advanceTimers = async (ms: number) => { jest.advanceTimersByTime(ms); await Promise.resolve(); }; // 使用示例:推进60秒触发一次轮询 await advanceTimers(60000); expect(callback).toBeCalledTimes(2);
内容的提问来源于stack exchange,提问作者Archimedes Trajano
相关产品推荐
相关产品推荐

