How to test your React hooks with React Testing Library and Jest
It’s been a while since I last wrote anything and I thought it would be a good idea to write on how to test a custom React hook.
I created this dedicated repo about it so you can just go now and play with the code or read the blog post first.
The app is simple, we make a request to an API and render the list of items that we get back. If the promise is pending we show a loading indication, if there’s an error we show the error.
function App() {
const { data, error, loading } = useFetchedData();
return (
<div className="App">
{loading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
{data && (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)}
</div>
);
}
We retrieve all the values from the useFetchedData
custom hook that we built. Let’s see what this hook does and what it returns.
const useFetchedData = () => {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(API_URL, {
signal: abortController.signal,
});
const json = await response.json();
setData(json);
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
};
fetchData();
return () => {
abortController.abort();
};
}, []);
return { data, error, loading };
};
Quick explanation of the code
Initially, we return an object containing the initial values of the state variables.
In theuseEffect
we send the request to the API, we then get the data back and we store it in the data
state variable and finally we update the loading
variable. That will cause a re-render in our component and we will return the new object with the updated values.
Also if an error occurs we store the error in the error
state variable and update the loading
variable to false
.
We also added a cleanup function to the useEffect
hook to abort the request if/when the component unmounts.
Test the initial state variables
import { renderHook } from "@testing-library/react";
import useFetchedData from "./useFetchedData";
// we will need this mock on our next test
global.fetch = jest.fn();
describe("useFetchedData", () => {
it("should return the initial values for data, error and loading", async () => {
const { result } = renderHook(() => useFetchedData());
const { data, error, loading } = result.current;
expect(data).toBe(null);
expect(error).toBe(null);
expect(loading).toBe(true);
});
});
In this test we just want to test the initial values of our custom hook before the async request has been fulfilled. We use renderHook
from @testing-library/react
to render the hook and then we can access the values of the state variables. We then add an assertion that the initial values are correct.
Test when the request has been fulfilled successfully
describe("when data is fetched successfully", () => {
let mockedData;
beforeEach(() => {
mockedData = [
{
body: "mocked body",
id: 1,
title: "mock title",
userId: 1,
},
];
global.fetch.mockResolvedValue({
json: jest.fn().mockResolvedValue(mockedData),
});
});
it("should return data", async () => {
const { result } = renderHook(() => useFetchedData());
await waitFor(() =>
expect(result.current).toEqual({
data: mockedData,
error: null,
loading: false,
})
);
});
});
We add one more describe
block in the pre-existing one to have our tests a bit more organised and we in a beforeEach
we mock the fetch
function to return some mockedData
. We then render the hook and we use waitFor
to wait for the promise to be resolved. We then assert that the data is the one we expect.
Ok that was the happy path, let’s now add the third test which is going to be our sad path. 😢 i.e when our API responds with an error.
Testing the loading property
describe("the loading property", () => {
it("should initially return true and then false", async () => {
const { result } = renderHook(() => useFetchedData());
const { loading } = result.current;
// asserting that the initial value of loading is true
// before the re-render
expect(loading).toBe(true);
await waitFor(() => {
const { loading } = result.current;
expect(loading).toBe(false);
});
});
});
Once again, we use renderHook
to render the hook and we assert that the initial value of theloading
property is true before the re-render happens. Then we wrap the second assertion in a waitFor
so that we sure we assert after the re-render.
Testing the error scenario
describe("when data is not fetched successfully", () => {
const mockedError = new Error("mocked error");
beforeEach(() => {
// we mock fetch to return a rejected value so that
// we add some coverage in the catch block in our code
fetch.mockRejectedValue(mockedError);
});
it("should return the Error", async () => {
const { result } = renderHook(() => useFetchedData());
await waitFor(() => {
const { error } = result.current;
expect(error).toBe(mockedError);
});
});
});
Similar to our happy path but different, we now use mockRejectedValue
from Jest to mock the fetch
function to return a rejected Promise with the mocked error. We then render the hook and, you guessed it, we wrap our assertion in a waitFor
to wait for the promise to be rejected and as a result our assertion to pass.
Quick side-note about coverage and testing in general
At this point our test coverage tool reports that we have 100% coverage. Is this true?
Technically yes but the coverage is not coming directly from our tests. RTL by default runs a cleanup function after each test and this function runs the cleanup function we have in our useEffect
. So that way the following code is covered:
return () => {
abortController.abort();
};
In this case it’s more of a library’s default behaviour that covers our code. But in real-life scenarios there will be times that you will get 100% coverage without adding tests for specific parts of the code. For instance consider the following code:
import saveToDadatabase from "./saveToDatabase";
const processData = (data) => {
data.processed = true;
saveToDadatabase(data);
return data;
};
We have the processData
that returns the data we passed but also saves the data to the database. Now consider the following test for this file:
import { processData } from "./processData";
describe("processData", () => {
it("should return processed data", () => {
const data = {
body: "mock body",
};
const processedData = processData(data);
expect(processedData).toEqual({
body: "mock body",
processed: true,
});
});
});
The test above asserts that when we pass a data object in the processData
function then we will get it back with the processed
property added and set to true
. In this exact scenario the coverage would be 100% and even if we remove the saveToDadatabase(data)
call, our tests would still pass! 😱
A good question that I ask myself to know I have covered everything is “If I removed this line of code would any of the tests fail?”. If not then I know I need to add one more test to cover that line of code and be more specific.
Anyway, we drifted away a bit but I think that was worth mentioning. Let’s add one more test to cover the cleanup function in our useEffect
.
Testing the cleanup function
describe("should abort the fetch request on unmount", () => {
const mockedAbortController = {
abort: jest.fn(),
};
beforeEach(() => {
global.AbortController = jest.fn(() => mockedAbortController);
});
it("should abort the fetch request", async () => {
const { unmount } = renderHook(() => useFetchedData());
unmount();
expect(mockedAbortController.abort).toHaveBeenCalled();
});
});
I hope you are starting to see the pattern here. Mocking something in the beforeEach and then asserting that it has been called.
Personally I like mocking my functions in the beforeEach
instead of the it block whenever I can. That way you usually make your assertions much leaner.
The new thing here is that we use the unmount
function that renderHook
returns, which basically unmounts our component and runs the cleanup function of our useEffect
.
Final Thoughts
You made it to the end!
You can clone the repo and give it a go. If you want more you will also find a test for the App.js
file that we saw in the beginning.
I hope this was/will be helpful and, as always, please share your feedback 🙂