Made in Builder.io

Upcoming webinar with Figma: Design to Code in 80% Less Time

Announcing Visual Copilot - Figma to production in half the time

Builder.io logo
Talk to Us
Platform
Developers
Talk to Us

Blog

Home

Resources

Blog

Forum

Github

Login

Signup

×

Visual CMS

Drag-and-drop visual editor and headless CMS for any tech stack

Theme Studio for Shopify

Build and optimize your Shopify-hosted storefront, no coding required

Resources

Blog

Get StartedLogin

‹ Back to blog

Web Development

How to Test Custom React Hooks with React Testing Library

May 9, 2023

Written By Vishwas Gopinath

Custom React hooks offer developers the ability to extract and reuse common functionality across multiple components. However, testing these hooks can be tricky, especially if you are new to testing. In this blog post, we will explore how to test a custom React hook using React Testing Library.

Testing React components

To start, let’s review how to test a basic React component. Let's consider the example of a Counter component that displays a count and a button that increments it when clicked. The Counter component takes an optional prop called initialCount which defaults to zero if not provided. Here's the code:

import { useState } from 'react'

type UseCounterProps = {
  initialCount?: number
}

export const Counter = ({ initialCount = 0 }: CounterProps = {}) => {
  const [count, setCount] = useState(initialCount)
  return (
    <div>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  )
}

To test the Counter component using React Testing Library, we follow these steps:

  1. Render the component using the render function from the React Testing Library.
  2. Get the DOM elements using the screen object from the React Testing Library. ByRole is the recommended way to query elements.
  3. Simulate user events using the @testing-library/user-event library.
  4. Assert against the rendered output.

The following tests verify the functionality of the Counter component:

import { render, screen } from '@testing-library/react'
import { Counter } from './Counter'
import user from '@testing-library/user-event'

describe('Counter', () => {
  test('renders a count of 0', () => {
    render(<Counter />)
    const countElement = screen.getByRole('heading')
    expect(countElement).toHaveTextContent('0')
  })

	test('renders a count of 1', () => {
    render(<Counter initialCount={1} />)
    const countElement = screen.getByRole('heading')
    expect(countElement).toHaveTextContent('1')
  })

  test('renders a count of 1 after clicking the increment button', async () => {
    user.setup()
    render(<Counter />)
    const incrementButton = screen.getByRole('button', { name: 'Increment' })
    await user.click(incrementButton)
    const countElement = screen.getByRole('heading')
    expect(countElement).toHaveTextContent('1')
  })
})

The first test verifies that the Counter component renders with a count of 0 by default. In the second test, we pass in a value of 1 for the initialCount prop and test whether the rendered count value is also 1.

Finally, the third test checks whether the Counter component updates the count correctly after the increment button is clicked.

Testing custom React hooks

Now, let's look at an example of a custom hook and how we can test it using the React Testing Library. We've extracted the counter logic into a custom React hook called useCounter.

The hook accepts an initial count as an optional prop and returns an object with the current count value and the increment function. Here's the code for the useCounter hook:

// useCounter.tsx
import { useState } from "react";

type UseCounterProps = {
  initialCount?: number
}

export const useCounter = ({ initialCount = 0 }: CounterProps = {}) => {
  const [count, setCount] = useState(initialCount);

  const increment = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return { count, increment };
};

Using this custom hook, we can easily add counter functionality to any component in our React application. Now, let's explore how to test it using React Testing Library.

// useCounter.test.tsx
import { render } from "@testing-library/react";
import { useCounter } from "./useCounter";

describe("useCounter", () => {
  test("should render the initial count", () => {
    render(useCounter) // Flags error
  });
})

Issues when testing custom React hooks

Testing custom React hooks is different from testing components. When you try to test the hook by passing it to the render() function, you'll receive a type error indicating that the hook cannot be assigned to a parameter of type ReactElement<any, string | JSXElementConstructor<any>>. This is because custom hooks do not return any JSX, unlike React components.

On the other hand, if you attempt to invoke the custom hook without the render() function, you'll see a console error in the terminal indicating that hooks can only be called inside function components.

// useCounter.test.tsx
import { render } from "@testing-library/react";
import { useCounter } from "./useCounter";

describe("useCounter", () => {
  test("should render the initial count", () => {
    useCounter() // Flags error
  });
})

Testing custom React hooks can indeed be tricky.

Testing custom hooks with renderHook()

To test custom hooks in React, we can use the renderHook() function provided by the React Testing Library. This function allows us to render a hook and access its return values. Let's see how we can update our test for the useCounter() hook to use renderHook():

// useCounter.test.tsx
import { renderHook } from "@testing-library/react";
import { useCounter } from "./useCounter";

describe("useCounter", () => {
  test("should render the initial count", () => {
    const { result } = renderHook(useCounter);
    expect(result.current.count).toBe(0);
  });
})

In this test, we use renderHook() to render our useCounter() hook and obtain its return value using the result object. We then use expect() to verify that the initial count is 0.

Note that the value is held in result.current. Think of result as a ref for the most recently committed value.

Using options with renderHook()

We can also test whether the hook accepts and renders the same initial count by passing an options object as the second argument to renderHook():

test("should accept and render the same initial count", () => {
    const { result } = renderHook(useCounter, {
      initialProps: { initialCount: 10 },
    });
    expect(result.current.count).toBe(10);
});

In this test, we pass an options object with an initialCount property set to 10 to our useCounter() hook using the initialProps option of the renderHook() function. We then use expect() to verify that the count is equal to 10.

Using act() to update the state

For our last test, let’s ensure the increment functionality works as expected.

To test whether the increment functionality of the useCounter() hook works as expected, we can use renderHook() to render the hook and call result.current.increment().

However, when we run the test, it fails with an error message, "Expected count to be 1 but received 0”.

test("should increment the count", () => {
    const { result } = renderHook(useCounter);
    result.current.increment();
    expect(result.current.count).toBe(1);
});
Console error: Use Counter should increment the amount. Expected 1, Received 0

The error message also provides a clue as to what went wrong: "An update to TestComponent inside a test was not wrapped in act(...)." This means that the code causing the state update, in this case the increment function, should have been wrapped in act(...).

In the React Testing Library, the act() helper function plays a vital role in ensuring that all updates made to a component are fully processed before making assertions.

Specifically, when testing code that involves state updates, it's essential to wrap that code with the act() function. This helps to simulate the behavior of the component accurately and ensure that your tests reflect real-world scenarios.

Note that act() is a helper function provided by the React Testing Library that wraps code that causes state updates. While the library typically wraps all such code in act(), this is not possible when testing custom hooks where we directly call functions that cause state updates. In such cases, we need to wrap the relevant code manually with act().

// useCounter.test.tsx
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

test("should increment the count", () => {
    const { result } = renderHook(useCounter);
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
});

By wrapping the increment() function with act(), we ensure that any modifications to the state are applied before we perform our assertion. This approach also helps avoid potential errors that may arise due to asynchronous updates.

Conclusion

When testing custom hooks using the React Testing Library, we use the renderHook() function to render our custom hook and verify that it returns the expected values. If our custom hook accepts props, we can pass them using the initialProps option of the renderHook() function.

Additionally, we must ensure that any code that causes state updates is wrapped with the act() utility function to prevent errors. For more information on testing React applications with Jest and React Testing Library, check out my React Testing playlist.

Introducing Visual Copilot: convert Figma designs to code using your existing components in a single click.

Try Visual Copilot

Share

Twitter
LinkedIn
Facebook
Hand written text that says "A drag and drop headless CMS?"

Introducing Visual Copilot:

A new AI model to turn Figma designs to high quality code using your components.

Try Visual Copilot
Newsletter

Like our content?

Join Our Newsletter

Continue Reading
AI9 MIN
How to Build AI Products That Don’t Flop
WRITTEN BYSteve Sewell
April 18, 2024
Web Development13 MIN
Convert Figma to Code with AI
WRITTEN BYVishwas Gopinath
April 18, 2024
Web Development8 MIN
Server-only Code in Next.js App Router
WRITTEN BYVishwas Gopinath
April 3, 2024