Web Front-end12 minute read

A Complete Guide to Testing React Hooks

A relatively recent addition to React, hooks have already changed React development for the better through improved code readability and state management. But how do we test them?

In this article, Toptal React Developer Avi Aryan outlines why it is crucial to test hooks and introduces us to his React Hooks testing routine.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

A relatively recent addition to React, hooks have already changed React development for the better through improved code readability and state management. But how do we test them?

In this article, Toptal React Developer Avi Aryan outlines why it is crucial to test hooks and introduces us to his React Hooks testing routine.


Toptalauthors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.
Avi Aryan
Verified Expert in Engineering

Avi is a full-stack developer skilled in Python, JavaScript, and Go. He’s also a multiple-time Google Summer of Code mentor.

Read More

Expertise

PREVIOUSLY AT

Google
Share

Hooks were introduced in React 16.8 in late 2018. They are functions that hook into a functional component and allow us to use state and component features like componentDidUpdate, componentDidMount, and more. This was not possible before.

Also, hooks allow us to reuse component and state logic across different components. This was tricky to do before. Therefore, hooks have been a game-changer.

In this article, we will explore how to test React Hooks. We will pick a sufficiently complex hook and work on testing it.

We expect that you are an avid React developer already familiar with React Hooks. In case you want to brush up your knowledge, you should check out our tutorial, and here’s the link to the official documentation.

The Hook We Will Use for Testing

For this article, we will use a hook that I wrote in my previous article, Stale-while-revalidate Data Fetching with React Hooks. The hook is called useStaleRefresh. If you haven’t read the article, don’t worry as I will recap that part here.

This is the hook we will be testing:

import { useState, useEffect } from "react";
const CACHE = {};

export default function useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // cacheID is how a cache is identified against a unique request
    const cacheID = url;
    // look in cache and set response if present
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      // else make sure loading set to true
      setLoading(true);
      setData(defaultValue);
    }
    // fetch new data
    fetch(url)
      .then((res) => res.json())
      .then((newData) => {
        CACHE[cacheID] = newData;
        setData(newData);
        setLoading(false);
      });
  }, [url, defaultValue]);

  return [data, isLoading];
}

As you can see, useStaleRefresh is a hook that helps fetch data from a URL while returning a cached version of the data, if it exists. It uses a simple in-memory store to hold the cache.

It also returns an isLoading value that is true if no data or cache is available yet. The client can use it to show a loading indicator. The isLoading value is set to false when cache or fresh response is available.

A flowchart tracking the stale-while-refresh logic

At this point, I will suggest you spend some time reading the above hook to get a complete understanding of what it does.

In this article, we will see how we can test this hook, first using no test libraries (only React Test Utilities and Jest) and then by using react-hooks-testing-library.

The motivation behind using no test libraries, i.e., only a test runner Jest, is to demonstrate how testing a hook works. With that knowledge, you will be able to debug any issues that may arise when using a library that provides testing abstraction.

Defining the Test Cases

Before we begin testing this hook, let’s come up with a plan of what we want to test. Since we know what the hook is supposed to do, here’s my eight-step plan for testing it:

  1. When the hook is mounted with URL url1, isLoading is true and data is defaultValue.
  2. After an asynchronous fetch request, the hook is updated with data data1 and isLoading is false.
  3. When the URL is changed to url2, isLoading becomes true again and data is defaultValue.
  4. After an asynchronous fetch request, the hook is updated with new data data2.
  5. Then, we change the URL back to url1. The data data1 is instantly received since it is cached. isLoading is false.
  6. After an asynchronous fetch request, when a fresh response is received, the data is updated to data3.
  7. Then, we change the URL back to url2. The data data2 is instantly received since it is cached. isLoading is false.
  8. After an asynchronous fetch request, when a fresh response is received, the data is updated to data4.

The test flow mentioned above clearly defines the trajectory of how the hook will function. Therefore, if we can ensure this test works, we are good.

Test flow

Testing Hooks Without a Library

In this section, we will see how to test hooks without using any libraries. This will provide us with an in-depth understanding of how to test React Hooks.

To begin this test, first, we would like to mock fetch. This is so we can have control over what the API returns. Here is the mocked fetch.

function fetchMock(url, suffix = "") {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve({
        json: () =>
          Promise.resolve({
            data: url + suffix,
          }),
      });
    }, 200 + Math.random() * 300)
  );
}

This modified fetch assumes that the response type is always JSON and it, by default, returns the parameter url as the data value. It also adds a random delay of between 200ms and 500ms to the response.

If we want to change the response, we simply set the second argument suffix to a non-empty string value.

At this point, you might ask, why the delay? Why don’t we just return the response instantly? This is because we want to replicate the real world as much as possible. We can’t test the hook correctly if we return it instantly. Sure, we can reduce the delay to 50-100ms for faster tests, but let’s not worry about that in this article.

With our fetch mock ready, we can set it to the fetch function. We use beforeAll and afterAll for doing so because this function is stateless so we don’t need to reset it after an individual test.

// runs before any tests start running
beforeAll(() => {
  jest.spyOn(global, "fetch").mockImplementation(fetchMock);
});

// runs after all tests have finished
afterAll(() => {
  global.fetch.mockClear();
});

Then, we need to mount the hook in a component. Why? Because hooks are just functions on their own. Only when used in components can they respond to useState, useEffect, etc.

So, we need to create a TestComponent that helps us mount our hook.

// defaultValue is a global variable to avoid changing the object pointer on re-render
// we can also deep compare `defaultValue` inside the hook's useEffect
const defaultValue = { data: "" };

function TestComponent({ url }) {
  const [data, isLoading] = useStaleRefresh(url, defaultValue);
  if (isLoading) {
    return <div>loading</div>;
  }
  return <div>{data.data}</div>;
}

This is a simple component that either renders the data or renders a “Loading” text prompt if data is loading (being fetched).

Once we have the test component, we need to mount it on the DOM. We use beforeEach and afterEach to mount and unmount our component for each test because we want to start with a fresh DOM before each test.

let container = null;

beforeEach(() => {
  // set up a DOM element as a render target
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // cleanup on exiting
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

Notice that container has to be a global variable since we want to have access to it for test assertions.

With that set, let’s do our first test where we render a URL url1, and since fetching the URL will take some time (see fetchMock), it should render “loading” text initially.

it("useStaleRefresh hook runs correctly", () => {
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("loading");
})

Run the test using yarn test, and it works as expected. Here’s the complete code on GitHub.

Now, let’s test when this loading text changes to the fetched response data, url1.

How do we do that? If you look at fetchMock, you see we wait for 200-500 milliseconds. What if we put a sleep in the test that waits for 500 milliseconds? It will cover all possible wait times. Let’s try that.

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

it("useStaleRefresh hook runs correctly", async () => {
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("loading");

  await sleep(500);
  expect(container.textContent).toBe("url1");
});

The test passes, but we see an error as well (code).

 PASS  src/useStaleRefresh.test.js
  ✓ useStaleRefresh hook runs correctly (519ms)

  console.error node_modules/react-dom/cjs/react-dom.development.js:88
    Warning: An update to TestComponent inside a test was not wrapped in act(...).

This is because the state update in useStaleRefresh hook happens outside act(). To make sure DOM updates are processed timely, React recommends you use act() around every time a re-render or UI update might happen. So, we need to wrap our sleep with act as this is the time the state update happens. After doing so, the error goes away.

import { act } from "react-dom/test-utils";
// ...
await act(() => sleep(500));

Now, run it again (code on GitHub). As expected, it passes without errors.

Let’s test the next situation where we first change the URL to url2, then check the loading screen, then wait for fetch response, and finally check the url2 text. Since we now know how to correctly wait for async changes, this should be easy.

act(() => {
  render(<TestComponent url="url2" />, container);
});
expect(container.textContent).toContain("loading");

await act(() => sleep(500));
expect(container.textContent).toBe("url2");

Run this test, and it passes as well. Now, we can also test the case where response data changes and the cache comes into play.

You will notice that we have an additional argument suffix in our fetchMock function. This is for changing the response data. So we update our fetch mock to use the suffix.

global.fetch.mockImplementation((url) => fetchMock(url, "__"));

Now, we can test the case where the URL is set to url1 again. It first loads url1 and then url1__. We can do the same for url2, and there should be no surprises.

it("useStaleRefresh hook runs correctly", async () => {
  // ...
  // new response
  global.fetch.mockImplementation((url) => fetchMock(url, "__"));

  // set url to url1 again
  act(() => {
    render(<TestComponent url="url1" />, container);
  });
  expect(container.textContent).toBe("url1");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url1__");

  // set url to url2 again
  act(() => {
    render(<TestComponent url="url2" />, container);
  });
  expect(container.textContent).toBe("url2");
  await act(() => sleep(500));
  expect(container.textContent).toBe("url2__");
});

This entire test gives us the confidence that the hook does indeed work as expected (code). Hurray! Now, let’s take a quick look at optimization using helper methods.

Optimizing Testing by Using Helper Methods

So far, we have seen how to completely test our hook. The approach is not perfect but it works. And yet, can we do better?

Yes. Notice that we are waiting for a fixed 500ms for each fetch to be completed, but each request takes anything from 200 to 500ms. So, we are clearly wasting time here. We can handle this better by just waiting for the time each request takes.

How do we do that? A simple technique is executing the assertion until it passes or a timeout is reached. Let’s create a waitFor function that does that.

async function waitFor(cb, timeout = 500) {
  const step = 10;
  let timeSpent = 0;
  let timedOut = false;

  while (true) {
    try {
      await sleep(step);
      timeSpent += step;
      cb();
      break;
    } catch {}
    if (timeSpent >= timeout) {
      timedOut = true;
      break;
    }
  }

  if (timedOut) {
    throw new Error("timeout");
  }
}

This function simply runs a callback (cb) inside a try...catch block every 10ms, and if the timeout is reached, it throws an error. This allows us to run an assertion until it passes in a safe manner (i.e., no infinite loops).

We can use it in our test as follows: Instead of sleeping for 500ms and then asserting, we use our waitFor function.

// INSTEAD OF 
await act(() => sleep(500));
expect(container.textContent).toBe("url1");
// WE DO
await act(() =>
  waitFor(() => {
    expect(container.textContent).toBe("url1");
  })
);

Do it in all such assertions, and we can see a considerable difference in how fast our test runs (code).

Now, all this is great, but maybe we don’t want to test the hook via UI. Maybe we want to test a hook using its return values. How do we do that?

It won’t be difficult because we already have access to our hook’s return values. They are just inside the component. If we can take those variables out to the global scope, it will work. So let’s do that.

Since we will be testing our hook via its return value and not rendered DOM, we can remove the HTML render from our component and make it render null. We should also remove the destructuring in hook’s return to make it more generic. Thus, we have this updated test component.

// global variable
let result;

function TestComponent({ url }) {
  result = useStaleRefresh(url, defaultValue);
  return null;
}

Now the hook’s return value is stored in result, a global variable. We can query it for our assertions.

// INSTEAD OF
expect(container.textContent).toContain("loading");
// WE DO
expect(result[1]).toBe(true);

// INSTEAD OF 
expect(container.textContent).toBe("url1");
// WE DO
expect(result[0].data).toBe("url1");

After we change it everywhere, we can see our tests are passing (code).

At this point, we get the gist of testing React Hooks. There are a few improvements we can still make, such as:

  1. Moving result variable to a local scope
  2. Removing the need to create a component for every hook we want to test

We can do it by creating a factory function that has a test component inside it. It should also render the hook in the test component and give us access to the result variable. Let’s see how we can do that.

First, we move TestComponent and result inside the function. We will also need to pass Hook and the Hook arguments as function’s arguments so that they can be used in our test component. Using that, here’s what we have. We are calling this function renderHook.

function renderHook(hook, args) {
  let result = {};

  function TestComponent({ hookArgs }) {
    result.current = hook(...hookArgs);
    return null;
  }

  act(() => {
    render(<TestComponent hookArgs={args} />, container);
  });

  return result;
}

The reason we have result as an object that stores data in result.current is because we want the return values to be updated as the test runs. The return value of our hook is an array, so it would have been copied by value if we returned it directly. By storing it in an object, we return a reference to that object so the return values can be updated by updating result.current.

Now, how do we go about updating the hook? Since we are already using a closure, let’s enclose another function rerender that can do that.

The final renderHook function looks like this:

function renderHook(hook, args) {
  let result = {};

  function TestComponent({ hookArgs }) {
    result.current = hook(...hookArgs);
    return null;
  }

  function rerender(args) {
    act(() => {
      render(<TestComponent hookArgs={args} />, container);
    });
  }

  rerender(args);
  return { result, rerender };
}

Now, we can use it in our test. Instead of using act and render, we do the following:

const { rerender, result } = renderHook(useStaleRefresh, [
  "url1",
  defaultValue,
]);

Then, we can assert using result.current and update the hook using rerender. Here’s a simple example:

rerender(["url2", defaultValue]);
expect(result.current[1]).toBe(true); // check isLoading is true

Once you change it in all places, you will see it works without any problems (code).

Brilliant! Now we have a much cleaner abstraction to test hooks. We can still do better - for example, defaultValue needs to be passed every time to rerender even though it doesn’t change. We can fix that.

But let’s not beat around the bush too much as we already have a library that improves this experience significantly.

Enter react-hooks-testing-library.

Testing Using React-hooks-testing-library

React-hooks-testing-library does everything we have talked about before and then some. For example, it handles container mounting and unmounting so you don’t have to do that in your test file. This allows us to focus on testing our hooks without getting distracted.

It comes with a renderHook function that returns rerender and result. It also returns wait, which is similar to waitFor, so you don’t have to implement it yourself.

Here is how we render a hook in React-hooks-testing-library. Notice the hook is passed in the form of a callback. This callback is run every time the test component re-renders.

const { result, wait, rerender } = renderHook(
  ({ url }) => useStaleRefresh(url, defaultValue),
  {
    initialProps: {
      url: "url1",
    },
  }
);

Then, we can test if the first render resulted in isLoading as true and return value as defaultValue by doing this. Exactly similar to what we implemented above.

expect(result.current[0]).toEqual(defaultValue);
expect(result.current[1]).toBe(true);

To test for async updates, we can use the wait method that renderHook returned. It comes wrapped with act() so we don’t need to wrap act() around it.

await wait(() => {
  expect(result.current[0].data).toEqual("url1");
});
expect(result.current[1]).toBe(false);

Then, we can use rerender to update it with new props. Notice we don’t need to pass defaultValue here.

rerender({ url: "url2" });

Finally, the rest of the test will proceed similarly (code).

Wrapping Up

My aim was to show you how to test React Hooks by taking an example of an async hook. I hope this helps you confidently tackle the testing of any kind of hook, as the same approach should apply to most of them.

I would recommend you use React-hooks-testing-library since it’s complete, and I haven’t run into significant problems with it thus far. In case you do encounter a problem, you now know how to approach it using the intricacies of testing hooks described in this article.

Understanding the basics

  • How do you test a React Hook?

    One can test React Hooks using a library such as react-hooks-testing-library. Testing a hook is similar to testing React components, and this library provides a convenient abstraction to do so.

  • Should you use React Hooks?

    React Hooks are completely stable, and many production codebases use only hooks and no class components. So one can use hooks however they want.

  • Why are React Hooks good?

    React Hooks allow sharing component lifecycle & state logic across multiple components. This allows developers to modularize both UI and logic. This was not possible before, hence hooks have been a major success.

  • Are React Hooks stable?

    Yes, React Hooks are stable and 100% backward compatible. One may feel free to use them in their codebase.

  • What are custom hooks?

    Custom hooks are hook functions created for a certain need using inbuilt React Hooks. They are named and can be used similarly to inbuilt hooks.

Hire a Toptal expert on this topic.
Hire Now
Avi Aryan

Avi Aryan

Verified Expert in Engineering

New Delhi, Delhi, India

Member since March 28, 2018

About the author

Avi is a full-stack developer skilled in Python, JavaScript, and Go. He’s also a multiple-time Google Summer of Code mentor.

Read More
authors are vetted experts in their fields and write on topics in which they have demonstrated experience. All of our content is peer reviewed and validated by Toptal experts in the same field.

Expertise

PREVIOUSLY AT

Google

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Join the Toptal® community.