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.
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;
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);
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
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
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();
});
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)
);
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();
// ...
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();
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([]);
});
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.
The act warning has been resolved with the react@^16.9.0 and @testing-library/react-hooks@^2.0.0 releases.
Top comments (4)
Author/Maintainer of react-hooks-testing-library here...
Firstly, thank you so much for wiriting this article. The library follows the all-contributors spec which acknowledges blog posts, so please feel free to submit a PR adding yourself to the table for this.
Secondly, the
act
warning has been resolved now with thereact@^16.9.0
and the@testing-library/react-hooks@^2.0.0
releases. None of the code in this article needs to change to stop the warning from appearing. I've made and updated version of the sandbox to show the tests passing without a warningHi Michael, awesome that you responded to me. I'm really happy that a maintainer of brilliant react-hooks-testing-library wrote a comment.
Thanks a lot for letting me know on how to resolve the warning. I updated the article!
Just what I wanted. Thanks!
You're welcome. I'm glad you like it.