You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用Jest模拟计时器正确测试React轮询自定义Hook

问题原因

你的问题主要来自两个点:

  1. useCallback依赖写法不规范:useRef的current值不能作为依赖项传入useCallback。useRef返回的对象在组件整个生命周期内都是稳定的,直接在回调函数里访问.current就能拿到最新值,不需要放到依赖数组里。原来的写法虽然不影响ref值读取,但是会违反React Hook的依赖规则,也会导致后续参数更新时回调无法正常刷新。
  2. 假计时器模式下没有手动清空微任务: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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.23 22:36:01