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

4

6

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.

Peterson answered 28/7, 2022 at 9:14 Comment(1)
Hey I'm finding the same issue now that I've updated to React 18 and the latest RTL 13.3 and jest 5.16.5. Have you found any solution?Remediosremedy
P
13

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

Peterson answered 24/8, 2022 at 16:48 Comment(2)
any idea, how to use it with the wrapper component, getting an error as "TypeError: Cannot read property 'getState' of undefined" when using Redux provider as the wrapper component?Localize
This is the wrong approach: - You should never need to wrap testing-library methods in act(), including renderHook and waitFor - In async tests, your callback is probably happening on a tick that's not during rendering the hook or taking action, so you should do something like await waitFor(() => expect(result.current.data).toEqual({ message: 'Text' }))Hildegard
E
2

Using act to call testing library methods is discouraged.

It's better to call the callback itself.

Eon answered 1/4, 2023 at 9:2 Comment(0)
M
1

Little bit late to the party but I've been struggling with the same issue for some time now and wanted to share my solution that doesn't involve calling the act method and also has typescripts type safety support.

import { renderHook, waitFor } from '@testing-library/react';

...

it('should set data correctly', async () => {
  const { result } = renderHook<useFetchPayload<Movie>, useFetchProps>(useFetch<Movie>, {
    initialProps: { url: 'success' },
  });

  await waitFor(() => {
    expect(result.current.data).toBe(mockedMovieResponse);
    expect(result.current.error).toBe(null);
    expect(result.current.loading).toBe(false);
  });
});

Notice that I use the renderHook and waitFor methods from @testing-library/react instead of the 'old way' using @testing-library/react-hooks as suggested in their own README.md.

Since v18 the renderHook method from react-hooks has been implemented by the react team. Using it with waitFor is the preferred way instead of using act (or the old waitForNextUpdate).

Manor answered 24/4, 2024 at 18:15 Comment(0)
R
0

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.

Remediosremedy answered 17/8, 2022 at 23:14 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.