Search code examples
react-hooksreact-testing-libraryvitest

Not able to test time related hook in vitest


Context: I am trying to write test cases for a custom react hook with internally uses time related api like setInterval. I am using vi.useFakeTimers to wrap the time related api and renderHook from @testing/library/react to render the hook.

Problem : I am trying to test if the mocked callback function is called n times in given timespan, but it is not being called even once.

Here is the custom react hook.

// useInterval.ts
import { useCallback, useEffect, useRef } from "react";

function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef<typeof callback>();

  useCallback(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    function tick() {
      savedCallback.current?.();
    }
    if (delay !== null) {
      const id = setInterval(tick, delay);

      return () => clearInterval(id);
    }
  }, [delay]);
}

export default useInterval;

and here is the test file.

// useInterval.test.ts
import { vi, expect, beforeEach, it, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import useInterval from "./useInterval";

describe("useInterval", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  test("should call the callback after the specified delay", () => {
    const mockCallback = vi.fn();

    const delay = 100;

    renderHook(() => useInterval(mockCallback, delay));

    vi.advanceTimersByTime(200);

    expect(mockCallback).toBeCalled(); // -> AssertionError: expected "spy" to be called at least once
  });
});

Here is my dependencies version -

"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.0.0",
"jsdom": "^24.0.0",
"vitest": "1.3.1"

Solution

  • I think you've mistakenly used the useCallback hook instead of the useEffect hook to cache the callback function. The useCallback hook memoizes and returns a function that would then need to be called in order for the body to execute and actually set the savedCallback ref value. The useEffect hook callback is invoked when the dependency changes.

    Swap out the useCallback for the useEffect hook so the passed callback reference is correctly cached in the savedCallback ref.

    import { useEffect, useRef } from "react";
    
    function useInterval(callback: () => void, delay: number | null) {
      const savedCallback = useRef<typeof callback>();
    
      useEffect(() => {
        savedCallback.current = callback;
      }, [callback]);
    
      useEffect(() => {
        function tick() {
          savedCallback.current?.();
        }
        if (delay !== null) {
          const id = setInterval(tick, delay);
    
          return () => clearInterval(id);
        }
      }, [delay]);
    }
    
    export default useInterval;