How to test your React hooks with React Testing Library and Jest

Vasilis Kortsimelidis
6 min readDec 16, 2022

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 errorstate 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 renderHookto 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?

Our report tool reports 100% coverage on useFetchedData hook

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 🙂

--

--