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"
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;