Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Testing the use of Promises with setTimeout in useEffect hook #241

Closed
erikshestopal opened this issue Dec 7, 2019 · 17 comments · Fixed by #408
Closed

Testing the use of Promises with setTimeout in useEffect hook #241

erikshestopal opened this issue Dec 7, 2019 · 17 comments · Fixed by #408
Labels
bug Something isn't working request for comment Discussion about changes to the library

Comments

@erikshestopal
Copy link

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);
});
@erikshestopal erikshestopal added the question Further information is requested label Dec 7, 2019
@mpeyper
Copy link
Member

mpeyper commented Dec 8, 2019

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.

@erikshestopal
Copy link
Author

@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);
};

@mpeyper
Copy link
Member

mpeyper commented Dec 8, 2019

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

@mpeyper
Copy link
Member

mpeyper commented Dec 8, 2019

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.

@mpeyper mpeyper added bug Something isn't working and removed question Further information is requested labels Dec 8, 2019
@mpeyper mpeyper added the request for comment Discussion about changes to the library label Dec 23, 2019
@FranckErnewein
Copy link

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

@chrismilson
Copy link

chrismilson commented Mar 6, 2020

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.

@giacomocerquone
Copy link

giacomocerquone commented Jul 1, 2020

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

@mpeyper
Copy link
Member

mpeyper commented Jul 15, 2020

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

@giacomocerquone
Copy link

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

@mpeyper
Copy link
Member

mpeyper commented Jul 23, 2020

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

@vladrose
Copy link

vladrose commented Nov 19, 2020

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()
    })
  })
})

@mpeyper
Copy link
Member

mpeyper commented Nov 19, 2020

Hi @vladrose,

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

@Bizzle-Dapp
Copy link

Bizzle-Dapp commented Mar 1, 2021

There didn't ever appear to be a clear, concise answer as to how to rectify the original posters solution.

Here is what 45 minutes of excessive digging has produced:

Code Behind:

import { useState, useEffect } from 'react';

function OmnipresentTimer() {
  // 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(timeOnApp + 1);
    }, 1000);
    return () => clearInterval(timer);
    })

    return { timeOnApp }
}

export default OmnipresentTimer;

Our test code:

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

afterEach(() => {
    jest.clearAllMocks()
  })

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

        expect(result.current.timeOnApp).toEqual(0);
        
        await waitForValueToChange(() => {
            return result.current.timeOnApp;
        });
        
        expect(result.current.timeOnApp).toEqual(1);
    })
})

It seems the key things to take away from this are:

  • the original poster was using await waitForNextUpdate(); which i believe is looking for a rerender. The hook returns a number, not jsx, therefore this never fires and times out.
  • jest.runAllTimers(); seems to do nothing in this case, instead initiate the use of faketimers and let your interval event inside the useEffect do the rest.

I'm fairly new to unit testing, so would appreciate any rectification surrounding my findings. But from what i've gathered. It checks out.

@mpeyper
Copy link
Member

mpeyper commented Mar 6, 2021

@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);
  })
})

@Bizzle-Dapp
Copy link

Bizzle-Dapp commented Mar 6, 2021

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

All of the other advice us sage though, my man. Many thanks!

@mpeyper
Copy link
Member

mpeyper commented Mar 6, 2021

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
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working request for comment Discussion about changes to the library
Projects
None yet
7 participants