Search code examples
reactjstypescriptreact-hooksreact-testing-libraryreact-hooks-testing-library

@testing-library/react-hooks calls setTimeout two times


Here is my custom React-hook.

import { useEffect, useRef } from 'react'

function useInterval({ callback, interval, delay }) {
  const savedTimerId = useRef<NodeJS.Timeout>()

  useEffect(() => {
    const loop = () => {
      const res = callback()
      const nextIteration = () => {
        savedTimerId.current = setTimeout(loop, interval)
      }
      if (res instanceof Promise) {
        res.then(nextIteration)
      } else {
        nextIteration()
      }
    }
    let delayedTimerId: NodeJS.Timeout
    if (!delay) {
      loop()
    } else {
      delayedTimerId = setTimeout(loop, delay)
    }
    return () => {
      // @ts-ignore
      clearTimeout(savedTimerId.current)
      if (delayedTimerId) {
        clearTimeout(delayedTimerId)
      }
    }
  }, [callback, interval, delay])
}

export { useInterval }

And here is the unit test

import { renderHook } from '@testing-library/react-hooks'
import { useInterval } from '../useInterval'

describe("Test scenarios for 'useInterval' hook", () => {
  jest.useFakeTimers()

  it("Should call 'callback' once", () => {
    const callback = jest.fn()
    const interval = 10000
    const params = { callback, interval }
    renderHook(() => useInterval(params))
    expect(setTimeout).toHaveBeenCalledTimes(1)
    expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), interval)
  })
})

But that's the output

Error: expect(jest.fn()).toHaveBeenCalledTimes(expected)

Expected number of calls: 1
Received number of calls: 2

I debugged this snippet. I found that before useInterval invocation something has already triggered setTimeout. debug result

Seems like setTimeout has been called internally. What am doing wrong? Any ideas?


Solution

  • You're absolutely right that @testing-library/react-hooks is calling setTimeout under the hood, and you can confirm this with:

    jest.useFakeTimers()
    renderHook(() => {})
    expect(setTimeout).toHaveBeenCalledTimes(1)
    

    You're probably better off focussing on how many times callback is called, rather than setTimeout:

    afterEach(() => {
      jest.clearAllMocks();
      jest.useRealTimers();
    });
    
    describe("Test scenarios for 'useInterval' hook", () => {
      it("Should call 'callback' immediately", () => {
        jest.useFakeTimers()
    
        const callback = jest.fn()
        const interval = 10000
        const params = { callback, interval }
        renderHook(() => useInterval(params))
    
        expect(callback).toHaveBeenCalledTimes(1)
      })
    
      it("Should call 'callback' repeatedly", () => {
        jest.useFakeTimers()
    
        const callback = jest.fn()
        const interval = 10000
        const params = { callback, interval }
        renderHook(() => useInterval(params))
    
        jest.advanceTimersByTime(interval * 2)
    
        // Expect 3 calls: 1 immediate call and 2 interval calls:
        expect(callback).toHaveBeenCalledTimes(3)
      })
    })