react-hooks-testing-library: `waitFor` doesn't work if jest fake timers are used

  • react-hooks-testing-library version: 7.0.0
  • react version: 17.0.2
  • react-dom version: 17.0.2
  • node version: 14.16.0
  • npm version: 7.10.0

Problem

When using waitFor when Jest has been configured to use fake timers then the waitFor will not work and only “polls” once. After that the test just hangs until Jest comes in and fails the test with that the test exceeds the timeout time. Below is some code that showcases the problem.

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

it('does not work', async () => {
  jest.useFakeTimers()

  const { waitFor } = renderHook(() => {})

  await waitFor(() => {
    console.log('poll') // Is printed just once
    expect(false).toBe(true)
  }, { timeout: 25, interval: 10 })

  // Fails with Exceeded timeout of 5000 ms for a test.
})

Basically the waitFor from @testing-library/react-hooks is using the faked setTimeout or setInterval which prevents it from working correctly.

There is a workaround (see suggested solution) but I recommend providing a nice error message when waitFor is used together with faked timers or maybe change the implemenation so it will work with fake timers.

Suggested solution

I found this issue and it seems that person has already been fixed in @testing-library/dom. From my perspective I can suggest maybe reuse that function instead of implementing it yourselves but I don’t really know the internal structure / code.

But after finding that issue and realizing that is has been fixed there, then I use the following code as a workaround which works fine.

import { waitFor } from '@testing-library/react'

it('works', async () => {
  jest.useFakeTimers()

  await waitFor(() => {
    console.log('poll') // Is printed twice
    expect(false).toBe(true)
  }, { timeout: 25, interval: 10 })

  // Fails with false is not equal to true
})

A more real world scenario

If curios on the actual problem I’m facing is to test the following hook:

function useSomething({ onSuccess }) {
  const poll = useCallback(async () => {
    const result = await fetch(/* ... */)
    if (result.ok) onSuccess()
  }, [onSuccess])

  useEffect(() => {
    const id = setInterval(() => { poll() }, 2000)
    return () => clearInterval(id)
  }, [poll])
}

What I want to do is test that it invokes the onSuccess function on a successfull poll.

it('invokes the `onSuccess` on successfull poll', async () => {
  const onSuccess = jest.fn()
  jest.useFakeTimers()

  const { waitFor } = renderHook(() => useSomething({ onSuccess }))

  jest.runOnlyPendingTimers()
  await waitFor(() => expect(onSuccess).toHaveBeenCalled())
})

About this issue

  • Original URL
  • State: open
  • Created 3 years ago
  • Reactions: 22
  • Comments: 45 (30 by maintainers)

Commits related to this issue

Most upvoted comments

As a workaround, you can switch back to using real timers immediately before invoking waitFor()

e.g.

// Arrange
jest.useFakeTimers();

// Act
doStuffThatTakesALongTime();

await jest.advanceTimersByTimeAsync(MAX_EXPECTED_TIME); // Fast forward

// Assert
jest.useRealTimers(); // Switch back to real timers so that we can use waitFor()
await waitFor(() => expect(inputControl).toBeEnabled()); 

I wanted only Date to be faked actually (while allowing all promises and intervals) hence I had to list implicitly other things as well:

jest
.useFakeTimers({
  doNotFake: [
    'setImmediate',
    'setInterval',
    'setTimeout',
    'cancelAnimationFrame',
    'cancelIdleCallback',
    'clearImmediate',
    'clearInterval',
    'clearTimeout',
    'nextTick',
    'queueMicrotask',
  ],
})
.setSystemTime(new Date('2022-07-31'));

waitFor under the hood uses setInterval which is also faked when using jest.useFakeTimers.

I’m very surprised no one has mentioned to tell jest not to fake other things it fakes.

This is an exhaustive list of things jest fakes when using jest.useFakeTimers. IMO it’s best to be very selective on what you let it fake, some things are not obvious

Can someone try jest.useFakeTimers({doNotFake: ['setInterval']});?

Thanks @enzoferey!!! I was struggling with this problem migrating tests to vite for a few hours… vi.useFakeTimers({ toFake: ['Date'], now: new Date(2022, 2, 20, 8, 0, 0) }); Works like a charm!

Very good pointer @joeythomaschaske 🎯

Indeed, waitFor uses setInterval, so if you use jest.useFakeTimers({ doNotFake: ["setInterval"] }) as you suggested it works.

For Vitest users, the API is a bit better as you can directly specify the only thing you want to fake. For example: vi.useFakeTimers({ toFake: ["setTimeout"] }).

My apologies @jcready for the waste of your time.

This library has been effectively deprecated for quite some time now.

While I understand upgrading to React 18 is no small feat, I do feel like it was a relevant thing for me to point out as many of my users would be looking to make that upgrade and along with it would likely need to migrate away from this library anyway. After all, React 18 has been out now for almost 2 years now.

All that being said, I’ll update the release notes to mention that it was not included to hopefully save someone else from wasting their time also.

Just had to update RTL, which resolved it for me.

The implementation in the alpha branch has some issues and I never found time to get to the bottom of it. It’s unlikely we’ll ever merge that into the main branch now.

For what it’s worth, the new RTL version already supports fake timers. I’m not sure about the new RNTL version though.

@chris110408 Yes, I think there is still work to be done here. Using fake timers stills fails in the scenario described here.

@bsk26 I would not expect that test to pass, regardless of the use of fake timers or not. The callback gets called synchronously once before using timeouts to poll for subsequent checks. With the current implementation, I’d expect the test to timeout with our error if not using fake timers, or jest’s timeout if you are. Can you please that you are awaiting the result of waitFor otherwise the test will breeze past it and appear to pass because it isn’t actually waiting for the promise to resolve:

const { result, waitFor } = renderHook(
  ...
);

await waitFor(() => {
  expect(false).toBe(true);
});

Does this mean the expected behavior for waitFor is effectively different than for @testing-library/react? (obviously this would not be ideal)

I assume you mean other than us not supporting the use of fake timers (yet)?

There has been a bit of deviation in our implementations which is not ideal, but for the most part the core use case is consistent, that is, it will run the callback on an interval until it stops throwing an error. We implemented it to also work of returning a boolean (unsupported by @testing-library/dom, where @testing-library/react get’s it’s implementation from) to simplify a common use case we see:

const { result, waitFor } = renderHook(() => useSomething())

await waitFor(() => !result.current.loading)

expect(result.current.data).toBe(...)

I suspect that this use case is less useful to @testing-library/react as the result of a loading state for them is a spinner in the DOM so they would use waitForElementToBeRemoved to check for it, but we deal with much more raw data being returned from hooks so we cannot rely on something external changing.

The other main difference is they now support returning a Promise from their callback which will delay running another interval check until the promise resolves. I actually didn’t realise this was a thing until looking this now to answer you and I conceptually like it and think it would be a great feature for us to support as well.

There are also some subtle differences in that they they run additional checks with a MutationObserver whereas we run them if render is called. The difference is simply because hooks do not rely on DOM changes, but rather component changes and in many cases we don’t even have a DOM in the test environment so we could not rely on the same implementation.

Fundamentally though, the implementation is different because our use cases and our dependencies are different. We try to offer the best utilities specifically for testing hook functions, rather than ensuring 100% API consistency with the libraries designed for testing DOM output.