Cannot test custom hooks with React 18 and renderHook from testing-library/react

Viewed 873

I have a cutom hook that makes an API call on mount and handles state (isLoading, isError, data, refetch);

The hook is quite simple:

    const useFetch = (endpoint, options) => {
    const [data, setData] = useState(null);
    const [error, setError] = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [trigger, setTrigger] = useState(true);

    const triggerSearch = () => {
        setTrigger(!trigger);
    };

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await fetch(
                    `${process.env.API_URL}${endpoint}`
                );
                const json = await response.json();
                setData(json);
                setIsLoading(false);
            } catch (error) {
                setError(error);
                setIsLoading(false);
            }
        };
        fetchData();
    }, [endpoint, trigger]);
    return {
        data,
        isLoading,
        error,
        triggerSearch,
    };
};

When trying to test the hook, I'm using jest and testing-library/react.

With react 18, the react-hooks from testing-library is no longer supported so I cannot use awaitForNextUpdate from renderHook as it doesn't return it.

Instead, we should use act and waitFor - which I have done and tests pass.

The problem is that I get the following error

Warning: An update to TestComponent inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):
test("should make an API call on mount", async () => {
    const hook = renderHook(() => useFetch("/api"));

    await act(async () => {
        await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
    });

    expect(fetch).toHaveBeenCalledTimes(1);

    expect(getAccessTokenSilently).toHaveBeenCalledTimes(1);

    expect(hook.result.current.data).toEqual({ message: "Test" });
    expect(hook.result.current.isLoading).toEqual(false);
    expect(hook.result.current.error).toEqual(null);
});

Could someone please point me in the right direction? I have tried removing all of the assertions and just calling renderHook, which also results in the same error.

2 Answers

Something like this might work.

test("should make an API call on mount", async () => {
  const hook = renderHook(() => useFetch("/api"));

  // remove the act method
  // you are not triggering an user action
  await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));

  expect(getAccessTokenSilently).toHaveBeenCalledTimes(1);

  expect(hook.result.current.data).toEqual({ message: "Test" });
  expect(hook.result.current.isLoading).toEqual(false);
  expect(hook.result.current.error).toEqual(null);
});

Or you could try advancing timers:

beforeAll(() => {
  // we're using fake timers because we don't want to
  // wait a full second for this test to run.
  jest.useFakeTimers();
});

afterAll(() => {
  jest.useRealTimers();
});

test("should make an API call on mount", () => {
  const hook = renderHook(() => useFetch("/api"));

  //advance timers by 1 second
  jest.advanceTimersByTime(1000);

  // remove the act method
  // you are not triggering an user action
  expect(fetch).toHaveBeenCalledTimes(1);

  expect(getAccessTokenSilently).toHaveBeenCalledTimes(1);

  expect(hook.result.current.data).toEqual({ message: "Test" });
  expect(hook.result.current.isLoading).toEqual(false);
  expect(hook.result.current.error).toEqual(null);
});

In my case I was using a click event to update the DOM and I had to use act during the fireEvent click event like so:

 it('open toggler assessment modal when clicking card button', async () => {
    renderTeacherAssessments();

    const assessmentCard = screen.getAllByTestId('assessment-card');
    const assessmentCardButton = assessmentCard[0].querySelector('button');

    act(() => {
      assessmentCardButton.click();
    });

    await waitFor(() => {
      expect(screen.getByTestId('confirm-modal')).toBeInTheDocument();
    });
  });

I've read in other posts regarding to the act warning where most of them would be fixed by just using await waitFor... wrapping the expect method, but now for React18 and the latest RTL I'm not sure that still works.

So the way I resolved the issue was to include everything that calls setState function inside async act (including the initialization of renderhook, because it calls the api via useEffect):

  describe("useFetch", () => {
    test("should make a call to the API and return the message", async () => {
        let hook;
        await act(async () => {
            hook = renderHook(() => useFetch("/api"));
        });
        const { result } = hook;
        expect(fetch).toHaveBeenCalledTimes(1);
        expect(getAccessTokenSilently).toHaveBeenCalledTimes(1);
        expect(result.current.data).toEqual({ message: "Test" });
        expect(result.current.isLoading).toEqual(false);
        expect(result.current.error).toEqual(null);
    });
});

Oh and make sure you are importing act from @testing-library/react

Related