Testing of a Custom React Hook for Fetching Data with Axios

doppelmutzi

Sebastian Weber

Posted on July 14, 2019

Testing of a Custom React Hook for Fetching Data with Axios

Hooks is a new concept of React. It requires some rethinking of existing knowledge. Furthermore, developing React components with hooks requires a mind shift (e.g., don't think in lifecycle methods). It needs some time to get comfortable, but with some practice hooks can be incorporated into real-life projects without problems. Custom hooks are very useful to encapsulate logic into isolated modules that can be easily reused.

However, testing hooks is (currently) no easy task. It took me quite some time to write working tests for my custom hooks. This post describes the crucial aspects for testing them.

Jooks is a library stating that React Hooks are f*ing hard to test.

You can find the code for the custom hook as well as the corresponding tests in my Code Sandbox.

The Custom Hook

This article expects you to know how to write custom React hooks. If you are new to this topic, check out React's documentation. Another good starting point is to take a look at awesome-react-hooks.

The following code snippet constitutes a simple custom hook to perform a GET request with axios.

// useFetch.js
import { useState, useEffect } from "react";
import axios from "axios";

// custom hook for performing GET request
const useFetch = (url, initialValue) => {
  const [data, setData] = useState(initialValue);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    const fetchData = async function() {
      try {
        setLoading(true);
        const response = await axios.get(url);
        if (response.status === 200) {
          setData(response.data);
        }
      } catch (error) {
        throw error;
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);
  return { loading, data };
};

export default useFetch;
Enter fullscreen mode Exit fullscreen mode

The following code shows how this custom hook can be used.

import React from "react";
import ReactDOM from "react-dom";
import "./styles.css";

import useFetch from "./useFetch";

function App() {
  const { loading, data } = useFetch(
    "https://jsonplaceholder.typicode.com/posts/"
  );

  return (
    <div className="App">
      {loading && <div className="loader" />}
      {data &&
        data.length > 0 &&
        data.map(blog => <p key={blog.id}>{blog.title}</p>)}
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Enter fullscreen mode Exit fullscreen mode

Testing the Custom Hook

At the time of this writing, testing hooks is no straight forward task. React's official documentation provides only a tiny section on this topic. I had a hard time to test hooks because of violations against the rules of hooks.

However, I've discovered react-hooks-testing-library that handles running hooks within the body of a function component, as well as providing various useful utility functions.

Before you write your tests, you need to install the library along with its peer dependencies as described in the documentation:

$ npm i -D @testing-library/react-hooks
$ npm i react@^16.8.0
$ npm i -D react-test-renderer@^16.8.0
Enter fullscreen mode Exit fullscreen mode

The custom hook utilizes axios for fetching data. We need a way to mock the actual networking. There are many ways to do this. I like axios-mock-adapter making it easy to write tests for successful and failing requests. You need to install these libraries, too.

$ npm i axios
$ npm i -D axios-mock-adapter
Enter fullscreen mode Exit fullscreen mode

First, take a look at the following Jest test, before we discuss the crucial parts.

// useFetch.test.js
import { renderHook } from "@testing-library/react-hooks";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";

import useFetch from "./useFetch";

test("useFetch performs GET request", async () => {
  const initialValue = [];
  const mock = new MockAdapter(axios);

  const mockData = "response";
  const url = "http://mock";
  mock.onGet(url).reply(200, mockData);

  const { result, waitForNextUpdate } = renderHook(() =>
    useFetch(url, initialValue)
  );

  expect(result.current.data).toEqual([]);
  expect(result.current.loading).toBeTruthy();

  await waitForNextUpdate();

  expect(result.current.data).toEqual("response");
  expect(result.current.loading).toBeFalsy();
});
Enter fullscreen mode Exit fullscreen mode

The implementation of useFetch performs a network request with axios. Therefore, we mock the GET request before we call useFetch.

// ...
const mock = new MockAdapter(axios);
// ...
/* 
  Mock network call. Instruct axios-mock-adapter 
  to return with expected data and status code of 200.
*/
mock.onGet(url).reply(200, mockData);
// invoke our custom hook
const { result, waitForNextUpdate } = renderHook(() =>
  useFetch(url, initialValue)
);
Enter fullscreen mode Exit fullscreen mode

As you can see, useFetch is wrapped in a renderHook function invocation. What this actually does is to provide the correct context to execute the custom hook without violating the rules of hooks (in this case that hooks can only be called inside the body of a function component).

The renderHook call returns a RenderHookResult. In our example, we destructure result and waitForNextUpdate from the result object. Let's discuss result first.

// ...
const { result, waitForNextUpdate } = renderHook(() =>
  useFetch(url, initialValue)
);

expect(result.current.data).toEqual([]);
expect(result.current.loading).toBeTruthy();
// ...
Enter fullscreen mode Exit fullscreen mode

result constitutes the renderHook result. As you can see in the expect statement, we can access the actual return value of our custom hook from result.current. So result.current.data and result.current.loading hold the return value of the custom hook call. These two assertions evaluate to true. The data state holds the passed initial value and the loading state is true because the actual network call has not been performed yet.

So far, so good, but how do we perform the call? Therefore, we need waitForNextUpdate.

// ...
const { result, waitForNextUpdate } = renderHook(() =>
  useFetch(url, initialValue)
);

expect(result.current.data).toEqual([]);
expect(result.current.loading).toBeTruthy();

await waitForNextUpdate();

expect(result.current.data).toEqual("response");
expect(result.current.loading).toBeFalsy();
Enter fullscreen mode Exit fullscreen mode

waitForNextUpdate allows us to wait for the asynchronous function to return in order to check the response of the network call.

The following extract is from the lib's documentation:

[...] returns a Promise that resolves the next time the hook renders, commonly when state is updated as the result of an asynchronous action [...].

After await waitForNextUpdate() returns we can safely assert that result.current.data holds data coming from the (mocked) network request. In addition, a state change by calling setLoading(false) was performed and, thus, result.current.loading is false.

Testing More Use Cases

In the following, you see a code snippet with two additional tests. The first one tests if our hook implementation can handle multiple invocations. The second one checks the network error case with the help of axios-mock-adapter.

test("useFetch performs multiple GET requests for different URLs", async () => {
  // fetch 1
  const initialValue = "initial value";
  const mock = new MockAdapter(axios);

  const mockData = 1;
  const url = "http://mock";
  mock.onGet(url).reply(200, mockData);

  const { result, waitForNextUpdate } = renderHook(() =>
    useFetch(url, initialValue)
  );

  expect(result.current.data).toEqual("initial value");
  expect(result.current.loading).toBeTruthy();

  await waitForNextUpdate();

  expect(result.current.data).toEqual(1);
  expect(result.current.loading).toBeFalsy();

  // fetch 2
  const url2 = "http://mock2";
  const mockData2 = 2;
  mock.onGet(url2).reply(200, mockData2);

  const initialValue2 = "initial value 2";
  const { result: result2, waitForNextUpdate: waitForNextUpdate2 } = renderHook(
    () => useFetch(url2, initialValue2)
  );

  expect(result2.current.data).toEqual("initial value 2");
  expect(result2.current.loading).toBeTruthy();

  await waitForNextUpdate2();

  expect(result2.current.data).toEqual(2);
  expect(result2.current.loading).toBeFalsy();
});

test("useFetch sets loading to false and 
returns inital value on network error", async () => {
  const mock = new MockAdapter(axios);

  const initialValue = [];
  const url = "http://mock";

  mock.onGet(url).networkError();

  const { result, waitForNextUpdate } = renderHook(() =>
    useFetch(url, initialValue)
  );

  expect(result.current.data).toEqual([]);
  expect(result.current.loading).toBeTruthy();

  await waitForNextUpdate();

  expect(result.current.loading).toBeFalsy();
  expect(result.current.data).toEqual([]);
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

I really like the API of react-hooks-testing-library. But what I like most is that the library enables me to test custom hooks in the first place. IMHO testing with this lib is straightforward.

If you see annoying warnings in the console as shown in the following screenshot, chances are high that you can fix it by updating your dependencies.
Annoying warnings in the console output.

The act warning has been resolved with the react@^16.9.0 and @testing-library/react-hooks@^2.0.0 releases.

💖 💪 🙅 🚩
doppelmutzi
Sebastian Weber

Posted on July 14, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related