The Ultimate Guide to Testing React Hooks

React 16.8, released in late 2018, introduced Hooks. These functions integrate with functional components, enabling the use of state and component features like componentDidUpdate and componentDidMount, which wasn’t possible before.

Furthermore, hooks facilitate the reuse of component and state logic across various components, a task that was previously quite challenging. Consequently, hooks have significantly transformed React development.

This article delves into testing React Hooks. We’ll select a sufficiently intricate hook and test it thoroughly.

This article assumes you’re an experienced React developer familiar with React Hooks. If you need a refresher, consult our tutorial available at official documentation.

The Hook We’ll Test

This article utilizes the useStaleRefresh hook, which I created for a previous article on Stale-while-revalidate Data Fetching with React Hooks. If you haven’t read it, don’t worry; I’ll provide a recap.

This is the hook we’ll be testing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 fetches data from a URL while returning a cached version if available. It employs a basic in-memory cache.

Additionally, it returns an isLoading value that indicates whether data or cache is currently available. If not, the client can display a loading indicator. Once cache or a fresh response is ready, isLoading becomes false.

A flowchart tracking the stale-while-refresh logic

Before we proceed, I encourage you to carefully review the hook’s code to understand its functionality fully.

We’ll explore testing this hook without any libraries (using only React Test Utilities and Jest) and then with the help of react-hooks-testing-library.

Testing without libraries, utilizing only the Jest test runner, demonstrates the fundamental principles of hook testing. This knowledge will empower you to troubleshoot any challenges that may occur when using a library that provides testing abstractions.

Defining Our Test Cases

Before diving into testing, let’s outline our test plan based on the hook’s intended behavior. Here’s my eight-step plan:

  1. Upon mounting the hook with URL url1, isLoading should be true, and data should be defaultValue.
  2. After an asynchronous fetch request, the hook should update with data data1, and isLoading should become false.
  3. Changing the URL to url2 should set isLoading to true again, with data as defaultValue.
  4. Following another asynchronous fetch, the hook should update with new data data2.
  5. Switching the URL back to url1 should instantly return cached data data1 with isLoading as false.
  6. After receiving a fresh response from an asynchronous fetch, data should update to data3.
  7. Reverting the URL to url2 should immediately yield cached data data2, and isLoading should be false.
  8. A fresh response from a subsequent asynchronous fetch should update the data to data4.

This test flow defines the hook’s expected behavior. If our tests confirm this flow, we can be confident in the hook’s functionality.

Test flow

Testing Hooks Without Libraries

This section demonstrates hook testing without external libraries, providing a deep dive into the underlying mechanisms.

First, we’ll mock the fetch function to control the API responses. Here’s the mocked fetch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function fetchMock(url, suffix = "") {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve({
        json: () =>
          Promise.resolve({
            data: url + suffix,
          }),
      });
    }, 200 + Math.random() * 300)
  );
}

This modified fetch assumes JSON responses and returns the provided url as the data value by default. It also introduces a random delay between 200ms and 500ms to simulate real-world conditions.

To modify the response, set the second argument suffix to a non-empty string.

You might wonder about the need for the delay. Why not an immediate response? The delay aims to replicate real-world scenarios more accurately. Testing the hook with instant responses wouldn’t be comprehensive. While we could shorten the delay for faster tests, let’s maintain realism for this article.

With our fetch mock ready, we’ll assign it to the fetch function. We’ll use beforeAll and afterAll for this purpose since the function is stateless and doesn’t require resetting after each test.

1
2
3
4
5
6
7
8
9
// runs before any tests start running
beforeAll(() => {
  jest.spyOn(global, "fetch").mockImplementation(fetchMock);
});

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

Next, we need to mount the hook within a component. Why? Because hooks are simply functions. They require a component context to interact with useState, useEffect, and other React features.

Let’s create a TestComponent to mount our hook:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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 simple component either displays the data or a “Loading” message while data is being fetched.

After creating the test component, we need to mount it to the DOM. We’ll employ beforeEach and afterEach to mount and unmount our component before each test to ensure a clean DOM.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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;
});

Note that container is a global variable, allowing access for test assertions.

With these preparations, let’s write our first test. We’ll render the URL url1, and since fetching takes time (due to fetchMock), it should initially display the “loading” text.

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

Running the test using yarn test confirms the expected behavior. Here’s the complete code on GitHub.

Now, let’s test the transition from “loading” to the fetched response data, url1.

How? Looking at fetchMock, we see a 200-500 millisecond wait time. What if we introduce a 500-millisecond sleep in the test? This should cover all potential delays.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 encounter an error (code).

1
2
3
4
5
 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 occurs because the state update in the useStaleRefresh hook happens outside act(). For timely DOM updates, React recommends using act() whenever re-renders or UI updates might occur. We’ll wrap our sleep with act to address this.

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

Running the test again (code on GitHub) should now be error-free.

Let’s test another scenario: changing the URL to url2, verifying the loading screen, awaiting the fetch response, and finally checking for the url2 text. This should be straightforward now that we know how to handle asynchronous changes.

1
2
3
4
5
6
7
act(() => {
  render(<TestComponent url="url2" />, container);
});
expect(container.textContent).toContain("loading");

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

This test also passes. Now, let’s test response data changes and cache behavior.

Our fetchMock function has an additional suffix argument to modify response data. We’ll update fetchMock accordingly.

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

Now, we can test setting the URL back to url1. It should initially load url1 and then url1__. We can replicate this for url2 without surprises.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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__");
});

Passing all these tests assures us that the hook functions as intended (code). Now, let’s optimize our tests with helper methods.

Optimizing with Helper Methods

While our current approach works, there’s room for improvement. For instance, we’re consistently waiting for 500ms after each fetch, even though requests can complete between 200ms and 500ms, resulting in wasted time. We can optimize this by waiting only as long as each request takes.

How? One technique is to repeatedly execute the assertion until it passes or a timeout is reached. Let’s create a waitFor function for this purpose.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 runs a callback (cb) within a try...catch block every 10ms. If the timeout is exceeded, it throws an error. This ensures safe assertion execution without infinite loops.

We can now replace our 500ms sleep with waitFor.

1
2
3
4
5
6
7
8
9
// INSTEAD OF 
await act(() => sleep(500));
expect(container.textContent).toBe("url1");
// WE DO
await act(() =>
  waitFor(() => {
    expect(container.textContent).toBe("url1");
  })
);

Implementing this across our assertions significantly speeds up our tests (code).

While testing through the UI works, we might want to test the hook directly using its return values. How can we achieve this?

It’s relatively simple. We already have access to the hook’s return values within the component. Moving these variables to the global scope would enable direct testing.

Since we’ll be testing via return values instead of the rendered DOM, we can remove the HTML rendering from our component and make it return null. We’ll also remove the destructuring in the hook’s return for increased generality.

Here’s the updated test component:

1
2
3
4
5
6
7
// global variable
let result;

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

Now, the global variable result stores the hook’s return value.

1
2
3
4
5
6
7
8
9
// 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 implementing these changes, our tests should still pass (code).

We now have a grasp of testing React Hooks. However, we can still make improvements such as:

  1. Moving the result variable to a local scope.
  2. Eliminating the need to create a component for each hook we test.

We can achieve this with a factory function that encapsulates a test component and renders the hook within it, providing access to the result variable.

Let’s move TestComponent and result inside a function called renderHook. We’ll also pass the hook and its arguments as function parameters for use within the test component.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function renderHook(hook, args) {
  let result = {};

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

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

  return result;
}

We store result as an object with result.current to enable updating return values as the test progresses. Since the hook returns an array, returning it directly would create a copy. Storing it in an object allows us to return a reference, facilitating updates by modifying result.current.

Now, let’s address hook updates. We’ll leverage the closure and add a rerender function.

The final renderHook function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 };
}

We can now use this function in our tests. Instead of act and render, we’ll do the following:

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

We can then assert using result.current and update the hook using rerender:

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

After applying these changes, our tests should run smoothly (code).

Now we have a much cleaner abstraction for testing hooks. While further refinements are possible (like avoiding the repetitive passing of defaultValue to rerender), we already have a library that significantly enhances this experience.

Let me introduce react-hooks-testing-library.

Testing with React-hooks-testing-library

React-hooks-testing-library handles many tasks we’ve discussed, including container mounting and unmounting, allowing us to focus solely on testing our hooks.

Its renderHook function returns rerender, result, and wait (similar to our waitFor).

Here’s how to render a hook using React-hooks-testing-library. The hook is passed as a callback, executed on every test component re-render.

1
2
3
4
5
6
7
8
const { result, wait, rerender } = renderHook(
  ({ url }) => useStaleRefresh(url, defaultValue),
  {
    initialProps: {
      url: "url1",
    },
  }
);

Testing if the initial render yields isLoading as true and a return value of defaultValue is straightforward.

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

For asynchronous updates, we utilize the wait method provided by renderHook, which comes pre-wrapped with act().

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

We can then update the hook with new props using rerender without passing defaultValue again.

1
rerender({ url: "url2" });

The rest of the test follows a similar pattern (code).

Wrapping Up

This article aimed to demonstrate React Hook testing using an asynchronous hook as an example. Hopefully, this equips you to confidently test various hook types, as the approach is generally applicable.

I highly recommend React-hooks-testing-library due to its comprehensiveness and reliability. However, if you encounter issues, the insights gained from this article should enable you to tackle them effectively.

Licensed under CC BY-NC-SA 4.0