Berkeli
Berkeli

Reputation: 434

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

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.

Upvotes: 7

Views: 17167

Answers (5)

Arman Peiravi
Arman Peiravi

Reputation: 1098

In case other people run into this and none of the above solutions worked, I had to wrap my expect logic inside a synchronous waitFor which was then wrapped inside an async act. For some reason the other combinations did not work, especially in cases where I was waiting for my result object from the hook to update its state (e.g. when result.current.someProperty changed during re-renders). This could point to an issue with the underlying hook itself, but everything was working great prior to updating React to version 18. This was for a migration from React 17 to React 18 using React Testing Library version 13.3.0:

describe("useFetch", () => {
    test("should make a call to the API and return the message", async () => {
        const { result } = renderHook(() => useFetch("/api"));

        await act(() => {
           waitFor(() => {
              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);
           }
        }
    });
});

Upvotes: 0

Tjeu Foolen
Tjeu Foolen

Reputation: 321

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

Upvotes: 1

Anyul Rivas
Anyul Rivas

Reputation: 735

Using act to call testing library methods is discouraged.

It's better to call the callback itself.

Upvotes: 2

Berkeli
Berkeli

Reputation: 434

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

Upvotes: 13

Fer Toasted
Fer Toasted

Reputation: 1738

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.

Upvotes: 0

Related Questions