testing-library / react-testing-library

🐐 Simple and complete React DOM testing utilities that encourage good testing practices.
https://testing-library.com/react
MIT License
19k stars 1.11k forks source link

calling jest.useFakeTimers() causes problems #244

Closed dadamssg closed 5 years ago

dadamssg commented 5 years ago

Relevant code or config:

import React, {useEffect, useReducer} from 'react'
import {render, waitForElement, flushEffects} from 'react-testing-library'

jest.useFakeTimers()

const initialState = {
  loading: false,
  slides: [],
  currentId: null
}

function reducer (state, action) {
  switch (action.type) {
    case 'FETCHING_SLIDES_REQUEST':
      return {...initialState, loading: true}
    case 'FETCHING_SLIDES_SUCCESS':
      return {
        ...state,
        loading: false,
        slides: action.slides,
        currentId: action.slides[0].id // set to first slide
      }
    case 'FETCHING_SLIDES_ERROR':
      return {...state, loading: false, error: action.error}
    default:
      return state
  }
}

const fetchSlides = () => Promise.resolve([
  {id: 'abc'},
  {id: 'def'},
  {id: 'ghi'}
])

function Slideshow () {
  const [state, dispatch] = useReducer(reducer, initialState)
  useEffect(() => {
    dispatch({type: 'FETCHING_SLIDES_REQUEST'})
    fetchSlides()
      .then(slides => dispatch({type: 'FETCHING_SLIDES_SUCCESS', slides}))
      .catch((error) => dispatch({type: 'FETCHING_SLIDES_ERROR', error}))
  }, [])
  const slide = state.slides.find(slide => slide.id === state.currentId)
  console.log(JSON.stringify({currentId: state.currentId, slide}))
  return (
    <button>{slide ? slide.id : null}</button>
  )
}

describe('Slideshow', () => {
  test('slideshow', async () => {
    const {getByText} = render(<Slideshow />)
    flushEffects()
    await waitForElement(() => getByText('abc'))
  })
})

What you did:

I'm building a slideshow component that will eventually auto-play based on a setTimeout. If i call jest.useFakeTimers(), the test fails. If i remove it, it passes. I've tried using rerender, jest.advanctTimersByTime(1), and flushEffects() in various places in conjunction with the previous.

What happened:

image

Reproduction:

You can just copy/paste the above snippet in a project that's using hooks. CodeSandbox doesn't support jest.useFakeTimers().

Problem description:

You can see in the screenshot, that the correct data is being logged so hypothetically it should show up in the dom but alas, it is not.

Suggested solution:

???

dadamssg commented 5 years ago

Of course I would finally stumble on some clue minutes after I submit an issue but days after trying to figure this out.

Apparently it has something to do with the jest.useFakeTimers() and promises.

https://stackoverflow.com/a/51132058/728241

After some testing, I've found that I need to advance the timers by an amount greater than 30 ms in an immediately resolving promise. I have no idea why that's the magic number. Or calling jest.runAllTimers() in the resolving promise also works.

Working test:

describe('Slideshow', () => {
  test('slideshow', async () => {
    const {getByText} = render(<Slideshow />)
    flushEffects()
    Promise.resolve().then(() => jest.advanceTimersByTime(30))
    // Promise.resolve().then(() => jest.runAllTimers()) <-- also works
    await waitForElement(() => getByText('abc'))
  })
})

This has nothing to do with react-testing-library but I feel like this should be documented somewhere. I just don't know where the appropriate place would be.

kentcdodds commented 5 years ago

Bummer. I wonder if you could make it work any better using lolex directly 🤔

Would you like to add this to the FAQ in the README?

marcinczenko commented 5 years ago

Don't you need to wrap the jest.useFakeTimers() in a promise as well?

I have something like this, and without rapping jest.useFakeTimers() in a promise, the test timeouts on the first await (basically nothing gets rendered).


it('hides the error message after sometime', async () => {
  Promise.resolve().then(() => jest.useFakeTimers())
  const reason = 'there is a special reason'
  const { getByText, queryByText, store: { dispatch } } = render(cogitoContract())
  dispatch(AppEventsActions.telepathError({ reason }))
  await waitForElement(() => getByText(reason))
  Promise.resolve().then(() => jest.advanceTimersByTime(3000))
  await wait(() => expect(queryByText(reason)).toBeNull())
})
kentcdodds commented 5 years ago

Thanks @marcinczenko!

I'm going to go ahead and close this one.

marcinczenko commented 5 years ago

I think it is fine to close. I have just one remark. I found out that in some cases, you may need to call jest.useFakeTimers() directly without wrapping it in a promise. So, if something does not work, try both options. Would be nice to further investigate, but as long I can get it to work, it is not the highest priority on my list - fortunately not so many tests need it. It may be related to having async componentDidMount() and/or calling setState in it, which always causes second render. In such cases it seems that jest.useFakeTimers() needs to be wrapped.

Noor0 commented 4 years ago

I've run into a same issue with testing a component in which a hook sleeps for sometime and then updates the state but no matter what I do I am unable to advance timer

this is what my code looks like, I've given up trying to mock timer in tests of this component but I wonder if my understanding of timers and queues in JS is flawed or if there's something wrong with jest.

const sleep = time =>
  new Promise((res, rej) => {
  setTimeout(res, time);
});

export default function MyComponent (props) {
  ...
  // my component
  ...

  const spring = useSpring({
    to: async (next, cancel) => {
    ...
    await sleep(300);
    setSomeState(false);
    ...
    },
    // other configurations for animation
  });

  return <div>MY UI</div>
}
kentcdodds commented 4 years ago

This is an old issue. If you feel like you've found a related issue, then please reproduce the issue using https://kcd.im/rtl-help and file a new issue on the relevant repository.

Thanks!