Search code examples
reactjsjestjses6-promisereact-testing-library

React testing library doesn't rerender the element until after the test has failed, even if I advance timers well after it should have updated


I'm trying to test a component where its value changes based on the result of a promise from a callback that is called after a certain duration.

My component looks like this. I have verified that it works as expected in my browser.

import React, { useEffect, useState } from 'react';

export interface TestComponentProps {
  callback: () => Promise<string>
}

const TestComponent: React.FC<TestComponentProps> = ({
  callback,
}: TestComponentProps) => {
  const [textDisplay, setTextDisplay] = useState('not resolved');
  const [timeLeft, setTimeLeft] = useState(1000);
  const [timerDone, setTimerDone] = useState(false);
  const timeStart = Date.now();

  const receiveText = async (text: Promise<string>) => {
    setTextDisplay(await text);
  }

  useEffect(() => {
    if (timerDone) {
      return () => undefined;
    }
    // Update screen every 50 ms
    const interval = setInterval(() => {
      const newTimeLeft = 1000 - Date.now() + timeStart;
      setTimeLeft(newTimeLeft);
      if (newTimeLeft <= 0) {
        setTimerDone(true);
        receiveText(callback());
      }
    }, 50);
    return () => clearInterval(interval);
  }, [timerDone]);

  if (!timerDone) {
    return (
    <p>{timeLeft} ms to go</p>
    );
  } else {
    return (
      <p>{textDisplay}</p>
    )
  }
};

export default TestComponent;

My test looks like this:

import { render, screen, act } from '@testing-library/react';
import React from 'react';
import TestComponent from './TestComponent';

it('renders the required information after the promise resolves', () => {
  jest.useFakeTimers();
  const callback = jest.fn().mockResolvedValue('Hello world');
  render(<TestComponent callback={callback} />);

  // Perform actions to update the state before waiting for the timer as required
  // ...

  // Now wait for the timer to finish - give it an extra 50ms just in case
  act(() => jest.advanceTimersByTime(1050));
  // The promise should have resolved and the text should have appeared in the document
  expect(screen.getByText('Hello world')).toBeInTheDocument();

  // The promise only resolves HERE
  // after the test will already have failed

  jest.clearAllTimers();
  jest.useRealTimers();
});

I cannot await new Promise(process.nextTick) (as per this question) as this combined with fake timers causes a deadlock.

I have tried instead using waitFor(() => expect(callback).toBeCalled()); as per this question, but it also fails the test - the breakpoints in my code that call the callback are never hit.

How can I ensure that the promise resolves and the component rerenders before I look for the text in the component whilst using fake timers?


Solution

  • I was able to fix this by making the act callback asynchronous and awaiting it. I have no idea why this worked, but it did.

    Here is the fixed test case. The edits are marked with HERE

    import { render, screen, act } from '@testing-library/react';
    import React from 'react';
    import TestComponent from './TestComponent';
    
    //                                                           HERE |
    //                                                                V
    it('renders the required information after the promise resolves', async () => {
      jest.useFakeTimers();
      const callback = jest.fn().mockResolvedValue('Hello world');
      render(<TestComponent callback={callback} />);
    
      // Perform actions to update the state before waiting for the timer as required
      // ...
    
      // Now wait for the timer to finish - give it an extra 50ms just in case
      //   HERE |
      //        V
      await act(async () => jest.advanceTimersByTime(1050));
      // The promise should have resolved and the text should have appeared in the document
      expect(screen.getByText('Hello world')).toBeInTheDocument();
    
      // The promise now resolves before HERE
      // meaning the test now passes!
    
      jest.clearAllTimers();
      jest.useRealTimers();
    });