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

Created on 8 Dec 2019  路  12Comments  路  Source: testing-library/react-hooks-testing-library

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);
});
bug request for comment

Most helpful comment

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.

All 12 comments

I'm not very familiar with mocking timers myself, but I think if you have called jest.runAllTimers() then the update should have occurred and there is nothing to wait for.

waitForNextUpdate is used when you want to asynchronously wait for the timeout to actually trigger.

If expect(result.current.count).toEqual(1) is not passing by just running the timers, then I'll take a closer look. I'm wondering if the function hoisting that JavaScript does means using that using setTimeout in a function in the same file as running useFakeTimers won't pick up the mocked timers (because the function gets declared first and captures the original setTimout), but I'll admit I'm far from an expert on the finer details of JavaScript execution.

@mpeyper The test is not passing by just running the timers. Just to reiterate, the test fails if I try to await the promise in this function used in useEffect :

const run = async () => {
 // waiting for the promise and having a setTimeout causes the test to to fail
  await Promise.resolve(); 

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

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

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.

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

I was having trouble as well, specifically with setInterval inside a useLayoutEffect.


Extra information

  • react-hooks-testing-library version: 3.2.1
  • react-test-renderer version: 16.13.0
  • react version: 16.13.0
  • node version: 12.14.0
  • yarn version: 1.22.0
  • typescript version: 3.8.3

Relevant code or config:

In the git repo.

What you did:

I ran a setInterval inside a useLayoutEffect (same problem with useEffect) hook and tried to advance it with jest.advanceTimersToNextTimer and jest's mock timers.

What happened:

image

Reproduction:

No codesandbox (jest.useFakeTimers is not implemented there) but I have a repo.

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

@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.

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

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

UseDelayEffect hook test. Hook is changing false on true with timeout

import { cleanup } from '@testing-library/react'
import { renderHook, act } from '@testing-library/react-hooks'
import useDelayEffect from './useDelayEffect'

jest.useFakeTimers()

// beforeAll(() => {})
// afterAll(() => {})
// beforeEach(() => {})
afterEach(() => {
  cleanup()
  jest.clearAllMocks()
})

describe('UseDelayEffect', () => {
  it('useDelayEffect', async () => {
    const { result, rerender } = renderHook(({ loading, delay }) => useDelayEffect(loading, delay), {
      initialProps: { loading: true, delay: 1000 }
    })

    expect(result.current).toBeTruthy()

    rerender({ loading: false, delay: 1000 })

    setTimeout(() => {
      expect(result.current).toBeFalsy()
    }, 1000) // Pass

    setTimeout(() => {
      expect(result.current).toBeFalsy()
    }, 900) // Fail

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

    rerender({ loading: true, delay: 5000 })
    rerender({ loading: false, delay: 5000 })

    setTimeout(() => {
      expect(result.current).toBeFalsy()
    }, 5000) // Pass

    setTimeout(() => {
      expect(result.current).toBeFalsy()
    }, 4450) // Fail

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

Hi @VladRose,

Can you share the useDelayEffect as well and perhaps a bit more explanation as to what your test is trying to achieve?

Was this page helpful?
0 / 5 - 0 ratings