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:
- Render the component using the
render
function from the React Testing Library. - Get the DOM elements using the
screen
object from the React Testing Library. ByRole is the recommended way to query elements. - Simulate user events using the
@testing-library/user-event
library. - 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);
});
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.
Tip: Visit our React and Builder.io Hub
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.