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
- Add interval to async utilities top supplement post render checks Resolves #241 Resolves #393 — committed to testing-library/react-hooks-testing-library by mpeyper 4 years ago
- Add interval to async utilities to supplement post render checks Resolves #241 Resolves #393 — committed to testing-library/react-hooks-testing-library by mpeyper 4 years ago
Ok, so I know why it isn’t working. It basically boils down to when
waitForNextUpdateresolves vs. when you need to calljest.runAllTimers(). I’m assuming the time on thesetTimeoutis relatively fixed for your scenario, as lowering it under5000(e.g.1000), removing the fake timers and just letting thewaitForNextUpdatedo 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:
renderHookcalledrenderHookexitsjest.runAllTimers()called (no-op - timer hasn’t started)waitForNextUpdatesetTimeoutcalledThe deadlock occurs here because
waitForNextUpdatedoes not resolve until the next render of the hook, and the set timeout wont fire until you calljest.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
setStateor 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,
waitForValueToChangeto 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:Unfortunately, it still times out. This time it’s because I forgot that both
waitandwaitForValueToChangeare built on top ofwaitForNextUpdateas 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 usingsetImmediate:Now the test follows this sequence of events:
renderHookcalledrenderHookexitswaitForNextUpdatesetTimeoutcalledjest.runAllTimers()calledsetStatecalledwaitForNextUpdateresolvesresult.current.counter === 1This 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
waitForValueToChangeutility is designed to work on changes to theresult.currentvalues (technically you could wait for any value to change, but it’s not a supported use case), and thewaitutility 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.
Ah, that’s because the default timeout on
waitForValueToChangeis 1000ms which is the same as your interval time. Overriding the timeout to something longer gives it time to change:Or just disabling the feature all together:
@Bizzle-Dapp I haven’t looked closely at or ran your code, but a few things from just reading you comment:
setTimeOnAppin your example.jest.useFakeTimers()at all if you’re using our async utils. They’ll wait for the real timers.useEffectwithout a dependency array will cleanup and create a new effect every render, making the use ofsetIntervalact more likesetTimout(it’ll only fire once before chatting cleaned up and a new interval is created)setTimeOnApp(time => time + 1)use...So with all that, my (untested) hook would look like
And my (untested) test:
@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 ofjest.runAllTimers()and it works perfectly.