Testing custom react hooks that use fetch (or other async behaviour)

Andre Calvo
Jun 27 · 4 min read

The custom hook

There are a few gotchas when testing custom hooks that have async behaviour, such as the fetch API. At the time of writing you have to use the alpha version (v16.9.0-alpha.0) of react, react-dom and react-test-renderer. Hopefully in the not too distant future this will be part of the stable react codebase.

Update: 16.9.0 is now stable, don’t use the alpha as mentioned above.

If you are getting errors such as…

Warning: An update to TestHook inside a test was not wrapped in act(...).When testing, code that causes React state updates should be wrapped into act(...):act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act
in TestHook
in Suspense

…this may help.

Here’s a simple example of a custom hook I wrote that calls an API using fetch:

Gist link

import { useEffect, useState, useRef } from "react";

export const useDataApi = () => {
const firstUpdate = useRef(true);
const [error, setError] = useState(false);
const [apiUrl, setApiUrl] = useState("");
const [data, setData] = useState(null);

useEffect(
() => {
if (!firstUpdate.current) {
fetch(apiUrl)
.then(response => response.json())
.then(response => {
setData(response);
})
.catch(error => {
setError(true);
});
}
firstUpdate.current = false;
},
[apiUrl]
);
return { data, error, callApi: setApiUrl };
};

I’m using useRef(true) to make sure it doesn’t make a call on it’s first render and useState to update the error/data state which is then returned for use by other components. useEffect is making sure the API call is only triggered when the apiUrl has been updated via setApiUrl.

It can be imported and used within another component by calling callApi with a string, like so:

...
const { data, error, callApi } = useDataApi();
...
function handleClick(type, filterValue) {
callApi("your/data/endpoint/here.com");
}
...
return(
...
{data.whateverIsReturned}
...
)

You can also use the error variables to conditionally show error messages.

Testing

My examples are using jest but you will need a few more libraries to get this to work.

Boilerplate

Your test file, without any tests should begin like this:

Gist link

import React from "react";
import { useDataApi } from "path/to/hoo/useDataApi.jsx";
import "whatwg-fetch";
import { renderHook } from "@testing-library/react-hooks";
import fetchMock from "fetch-mock";
import { act } from "react-test-renderer";

describe("useDataApi", () => {
beforeAll(() => {
global.fetch = fetch;
});
afterAll(() => {
fetchMock.restore();
});
});

In our beforeAll and afterAll we set global.fetch to a polyfilled version of fetch so it can be used by your jest tests, we then restore this after the tests have finished.

The tests

There are a few things to remember.

  • Use act to wrap any behaviours which update the state of the behaviour, in our case it’s calling callApi but this could also be the clicking of a button for example.
  • As fetch is async, we must use await act(async () => {...})

Testing a successful response

Gist link

import React from "react";
import { useDataApi } from "path/to/hoo/useDataApi.jsx";
import "whatwg-fetch";
import { renderHook } from "@testing-library/react-hooks";
import fetchMock from "fetch-mock";
import { act } from "react-test-renderer";

describe("useDataApi", () => {
beforeAll(() => {
global.fetch = fetch;
});
afterAll(() => {
fetchMock.restore();
});

it("should return data with a successful request", async () => {
const { result } = renderHook(() => useDataApi());
fetchMock.mock("test.com", {
returnedData: "foo"
});
await act(async () => {
result.current.callApi("test.com");
});

expect(result.current.data).toBe({
returnedData: "foo"
});
});
});

Testing an erroneous response

Gist link

import React from "react";
import { useDataApi } from "path/to/hoo/useDataApi.jsx";
import "whatwg-fetch";
import { renderHook } from "@testing-library/react-hooks";
import fetchMock from "fetch-mock";
import { act } from "react-test-renderer";

describe("useDataApi", () => {
beforeAll(() => {
global.fetch = fetch;
});
afterAll(() => {
fetchMock.restore();
});

it("should return error as true if api error", async () => {
const { result } = renderHook(() => useDataApi());

fetchMock.mock("test.com", 500);

await act(async () => {
result.current.callApi("test.com");
});

expect(result.current.data).toBe(null);
expect(result.current.error).toBe(true);
});
});

All Gists can be found here.

Andre Calvo

Written by

Freelance senior developer @ andrecalvo.co.uk. Brighton, UK.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade