react-hooks-testing-library: Testing the use of Promises with setTimeout in useEffect hook

Hey there! I’m having an issue testing a custom hook that uses an async function in the useEffect hook. If I try to await a promise inside of the run function, my test times out if I use waitForNextUpdate. What am I doing wrong and how can I fix this behavior?

import { renderHook, act } from '@testing-library/react-hooks';
import { useState, useEffect, useContext } from 'react';

// using fake timers 
jest.useFakeTimers();

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const run = async () => {
      // await Promise.resolve(); // If I remove this line, test passes.

      setTimeout(() => {
        setCount(count => count + 1);
      }, 5000);
    };

    run();
  }, []);

  return { count };
}

test('it should work', async () => {
  const { result, waitForNextUpdate } = renderHook(() => Counter());

 act(() => {
    jest.runAllTimers();
  });

// await waitForNextUpdate(); this line triggers the Jest 5000ms timeout error. 

  expect(result.current.count).toEqual(1);
});

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Reactions: 11
  • Comments: 17 (8 by maintainers)

Commits related to this issue

Most upvoted comments

Ok, so I know why it isn’t working. It basically boils down to when waitForNextUpdate resolves vs. when you need to call jest.runAllTimers(). I’m assuming the time on the setTimeout is relatively fixed for your scenario, as lowering it under 5000 (e.g. 1000), removing the fake timers and just letting the waitForNextUpdate do it’s thing allows the test to pass (albeit after a second of waiting), so I’ll work on the understanding that using a mocked timer is important.

Your test follows the following sequence of events:

  1. renderHook called
  2. hook renders
  3. effect starts
  4. promise starts
  5. renderHook exits
  6. jest.runAllTimers() called (no-op - timer hasn’t started)
  7. start waitForNextUpdate
  8. promise resolves
  9. setTimeout called
  10. deadlock
  11. test times out

The deadlock occurs here because waitForNextUpdate does not resolve until the next render of the hook, and the set timeout wont fire until you call jest.runAllTimers(), which has already been and gone because the promise causes it to miss a beat.

My initial reaction, was oh, that’s easy, I’ll just wait first for the promise first, then run the timers, but unfortunately this also doesn’t work because there is not setState or other render trigger between awaiting the promise and setting the timeout, so again, the test times out waiting.

My next thought was that I could use one of the other async utils, waitForValueToChange to periodically test for result.current.counterto change and throw a cheekyjest.runAllTimers()` in the callback to allow the timeout to fire in between checks, like so:

  const { result, waitForValueToChange } = renderHook(() => Counter());

  await waitForValueToChange(() => {
    jest.runAllTimers();
    return result.current.count;
  });

  expect(result.current.count).toEqual(1);

Unfortunately, it still times out. This time it’s because I forgot that both wait and waitForValueToChange are built on top of waitForNextUpdate as their primitive utility so nothing is checked if the hook doesn’t render.

Finally, I was able to get the test to pass by delaying when jest.runAllTimers() is called using setImmediate:

  const { result, waitForNextUpdate, waitForValueToChange } = renderHook(() => Counter());

  setImmediate(() => {
    act(() => {
      jest.runAllTimers();
    });
  });

  await waitForNextUpdate();

  expect(result.current.count).toEqual(1);

Now the test follows this sequence of events:

  1. renderHook called
  2. hook renders
  3. effect starts
  4. promise starts
  5. renderHook exits
  6. start waitForNextUpdate
  7. promise resolves
  8. setTimeout called
  9. jest.runAllTimers() called
  10. timeout fires
  11. setState called
  12. hook renders
  13. waitForNextUpdate resolves
  14. assert result.current.counter === 1
  15. test passes

This works, but is very brittle for changes to the hook’s flow and is definitely testing implementation details (which we should try to avoid).

I’m not 100% sure how to proceed on this one. The waitForValueToChange utility is designed to work on changes to the result.current values (technically you could wait for any value to change, but it’s not a supported use case), and the wait utility is designed for a similar use case but when exceptions are involved, so I’m not sure if the semantics of when the checks run are actually wrong. I’m actually struggling to think of any reason other than mixing promises and mocked timers that I would need to wait an arbitrary amount of time. Perhaps there is a missing concept in our API for handling this kind of thing? Perhaps some/all of the async utils should run checks on a timer instead of renders (or perhaps both)?

I’ll think on this and I’m happy to take suggestions and feedback in this issue.

Hmm, ok. I’ll take a look after the kids go to bed tonight.

Hmm, removing the fakeTimers like in your code causes the test to time out.

Ah, that’s because the default timeout on waitForValueToChange is 1000ms which is the same as your interval time. Overriding the timeout to something longer gives it time to change:

await waitForValueToChange(() => result.current.timeOnApp, { timeout: 2000 });

Or just disabling the feature all together:

await waitForValueToChange(() => result.current.timeOnApp, { timeout: false });

@Bizzle-Dapp I haven’t looked closely at or ran your code, but a few things from just reading you comment:

  1. Older versions of the library required a rerender to test for the value changes, but newer versions have interval checks now as well, i.e it tests the condition if the hook rerenders or at the next interval, whichever comes first.
  2. The render it waits for is the render of the test component we render under the hood, so it does not matter whet the hook returns. Rerenders occur when the hook updates it’s state, like setTimeOnApp in your example.
  3. You don’t need jest.useFakeTimers() at all if you’re using our async utils. They’ll wait for the real timers.
  4. useEffect without a dependency array will cleanup and create a new effect every render, making the use of setInterval act more like setTimout (it’ll only fire once before chatting cleaned up and a new interval is created)
  5. If updating state that relies on the old state, you should instead use the state update callback instead, e.g. setTimeOnApp(time => time + 1)
  6. As a convention, hook functions should start with use...

So with all that, my (untested) hook would look like

import { useState, useEffect } from 'react';

function useOmnipresentTimer() {
  // State to retain the time spent on App
  const [timeOnApp, setTimeOnApp] = useState<number>(0);

  // Use a continous looping useEffect to create a timer for how long someone has been on the app, in seconds.
  useEffect(() => {
    let timer = setInterval(() => {
      setTimeOnApp(time => time + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  return { timeOnApp }
}

export default useOmnipresentTimer;

And my (untested) test:

import { renderHook } from '@testing-library/react-hooks';
import useOmnipresentTimer from '../OmnipresentTimer';

describe("App holds a continuous timer tick", () => {
  test("if after a second has passed, the interval has increased", async () => {
    const { result, waitForValueToChange } = renderHook(() => useOmnipresentTimer());

    expect(result.current.timeOnApp).toBe(0);
        
    await waitForValueToChange(() => result.current.timeOnApp);
        
    expect(result.current.timeOnApp).toBe(1);
  })
})

@mpeyper sorry but I’m too busy at work, if it’s still needed I can recreate a repro

anyone knows how to properly test these kind of implementations? fakeTimers() didn’t work for me…

Legend. Works an absolute charm. Thanks for clarifying all of that! 🐱‍👤

Yes please. Perhaps raise a new issue when you have time and I’ll dig into the specifics of your situation there.

@giacomocerquone can you elaborate on what your hook/test look like?

For what it’s worth, I’ve made a start on #393 so some of the issues will go away soon, but the chicken and egg problem of triggering an update while waiting for the change is unlikely to result in a a clean reading test. Open to idea on how you’d like to write your test, and see if we can make something work along those lines.

Thank you for @mpeyper ! I my case I used jest.useFakeTimers() instead of jest.runAllTimers() and it works perfectly.