JavaScript Testing

Testing hooks with react-hooks-testing-library and Redux

Marcin Wanago
JavaScriptReactTesting

The hooks are an exciting addition to React and undoubtedly one that helps us to separate the logic from the template. Doing so makes the above logic more testable. Unfortunately, testing hooks does not prove to be that straightforward. In this article, we look into how we can deal with it using react-hooks-testing-library.

Identifying the tricky part

To understand what makes testing React hooks problematic, let’s create a simple custom hook. We will base it on a hook from The Facade pattern and applying it to React Hooks.

1import { useState } from 'react';
2 
3function useModalManagement() {
4  const [isModalOpened, setModalVisibility] = useState(false);
5 
6  function openModal() {
7    setModalVisibility(true);
8  }
9 
10  function closeModal() {
11    setModalVisibility(false);
12  }
13  
14  return {
15    isModalOpened,
16    openModal,
17    closeModal
18  }
19}

The above hook does a straightforward job managing the modal state. Let’s start by testing if it does not throw any errors.

1import useModalManagement from './useModalManagement';
2 
3describe('The useModalManagement hook', () => {
4  it('should not throw an error', () => {
5    useModalManagement();
6  })
7});
1FAIL useModalManagement.test.js
2  The useModalManagement hook
3    ✕ should not throw an error

Unfortunately, a test like the one above would not work. We can figure out the reason by reading the error message:

Invalid hook call. Hooks can only be called inside of the body of a function component.

The React documentation confirms the above. We can only call hooks from function components, or other hooks. We could fix this issue using the enzyme library that we’ve covered in the previous part of this series and with a bit of cleverness:

1import React from 'react';
2import { shallow } from 'enzyme';
3 
4function testHook(hook) {
5  let output;
6  function HookWrapper() {
7    output = hook();
8    return (
9      <></>
10    );
11  }
12  shallow(<HookWrapper />);
13  return output;
14}
1import useModalManagement from './useModalManagement';
2import testHook from './testHook';
3 
4describe('The useModalManagement hook', () => {
5  it('should not throw an error', () => {
6    testHook(useModalManagement);
7  });
8});
1PASS useModalManagement.test.js
2  The useModalManagement hook
3    ✓ should not throw an error

Much better! The above solution is not very elegant, though, and does not provide us with a comfortable way to test our hook further. This is the reason for us to use the react-hooks-testing-library.

Introducing react-hooks-testing-library

The above provides us with utilities created solely for testing hooks. Its purpose is to mimic the experience of using hooks from within real components. Its renderHook function acts in a similar way to our  testHook function that we’ve created before. It expects a callback that calls at least one hook.

Let’s install react-hooks-testing-library using  @testing-library/react-hooks and write our first test:

1import useModalManagement from './useModalManagement';
2import { renderHook } from '@testing-library/react-hooks';
3 
4describe('The useModalManagement hook', () => {
5  it('should not throw an error', () => {
6    renderHook(() => useModalManagement());
7  });
8});

The object returned by the renderHook function contains, among other things, the result. It two properties:

  • current – it reflects the return value of our hook
  • error – reflects the error thrown inside of a hook, if there was any
1import useModalManagement from './useModalManagement';
2import { renderHook } from '@testing-library/react-hooks';
3 
4describe('The useModalManagement hook', () => {
5  it('should describe a closed modal by default', () => {
6    const { result } = renderHook(() => useModalManagement());
7    expect (result.current.isModalOpened).toBe(false);
8  });
9});

The current object also contains the  openModal and  closeModal function. The documentation advises us to wrap functions, updating the state inside of the act utility. It simulates how the hooks work in a browser. Not using it results in a warning in the console.

1import useModalManagement from './useModalManagement';
2import { renderHook, act } from '@testing-library/react-hooks';
3 
4describe('The useModalManagement hook', () => {
5  describe('when the openModal function is called', () => {
6    it('should describe an opened modal', () => {
7      const { result } = renderHook(() => useModalManagement());
8      act(() => {
9        result.current.openModal();
10      });
11      expect (result.current.isModalOpened).toBe(true);
12    });
13  })
14});
1PASS useModalManagement.test.js
2  The useModalManagement hook
3    when the openModal function is called
4      ✓ should describe an opened modal (3ms)

Sometimes we need to pass arguments to the hook.

1const { result } = renderHook(() => useModalManagement(true));
2// setting the default state to true

When doing the above, you might run into some corner cases. For a detailed explanation, please check the documentation.

Dealing with asynchronous hooks

There are sometimes situations in which hooks trigger asynchronous actions that the  current object does not reflect at first. Let’s write a very simple hook that interacts with some API:

1import { useState } from 'react';
2 
3function useCommentsManagement() {
4  const [comments, setComments] = useState([]);
5 
6  function fetchComments() {
7    return fetch('https://jsonplaceholder.typicode.com/comments')
8      .then(response => response.json())
9      .then((data) => {
10        setComments(data);
11      })
12  }
13 
14  return {
15    comments,
16    fetchComments
17  }
18}

We want to test if successfully fetching comments causes the state to change. To wait for the  fetchComments function to finish, we can use the waitForNextUpdate utility. It returns a promise that resolves next time the hook renders – typically due to an asynchronous update.

1import { renderHook, act } from '@testing-library/react-hooks';
2import useCommentsManagement from './useCommentsManagement';
3 
4describe('The useCommentsManagement hook', () => {
5  describe('when the fetchComments function is called', () => {
6    it('should update the state after a successful request', async () => {
7      const { result, waitForNextUpdate } = renderHook(() => useCommentsManagement());
8 
9      act(() => {
10        result.current.fetchComments();
11      });
12      await waitForNextUpdate();
13 
14      return expect(result.current.comments.length).not.toBe(0);
15    });
16  })
17});

Our test might fail due to the API not working properly and we want to avoid that. Remember to mock the API first! If you want more details on how to do the above, check out, Mocking API calls and simulating React components interactions.

We can be more specific than just waiting for any update. With the use of the waitForValueToChange utility, we can wait for a particular value to change. To do the above, we provide a selector that returns the value that we want to wait for.

1const { result, waitForValueToChange } = renderHook(() => useCommentsManagement());
2 
3act(() => {
4  result.current.fetchComments();
5});
6await waitForValueToChange(() => {
7  return result.current.comments;
8});

We also have the wait utility. The promise it returns resolves when the provided callback returns a truthy value, or undefined. We can pass additional options to all async utilities, such as the maximum time to wait. For a full list, check out the documentation.

Testing hooks with Redux

Our projects often use some state management, such as Redux or the context built into React. Let’s rewrite our useModalMangement hook to use Redux.

1import { useDispatch, useSelector } from 'react-redux';
2import modalActions from './modalActions.js'
3 
4function useModalManagement() {
5  const isModalOpened = useSelector(state => state.modal.isOpened);
6  const dispatch = useDispatch();
7 
8  function openModal() {
9    dispatch(modalActions.openModal());
10  }
11 
12  function closeModal() {
13    dispatch(modalActions.closeModal());
14  }
15  
16  return {
17    isModalOpened,
18    openModal,
19    closeModal
20  }
21}
A cool thing is that the return values for the  useModalManagement didn’t change. It shows how good React hooks are when it comes to refactoring.
1import useModalManagement from './useModalManagement';
2import { renderHook } from '@testing-library/react-hooks';
3 
4describe('The useModalManagement hook', () => {
5  it('should describe a closed modal by default', () => {
6    const { result } = renderHook(() => useModalManagement());
7 
8    expect (result.current.isModalOpened).toBe(false);
9  });
10});

Unfortunately, this time, the test fails. We can see the following error message:

Invariant Violation: could not find react-redux context value; please ensure the component is wrapped in a <Provider>

This is due to the fact that we didn’t so far provide any store for our hook to use. We can do it with the use of a second parameter of the renderHook function. When we pass the redux provider to the wrapper property of the options, the test component that uses our hook under the hood gets wrapped.

1import React from 'react';
2import useModalManagement from './useModalManagement';
3import { renderHook } from '@testing-library/react-hooks';
4import store from '../../store';
5import { Provider } from 'react-redux';
6 
7describe('The useModalManagement hook', () => {
8  it('should describe a closed modal by default', () => {
9    const { result } = renderHook(() => useModalManagement(), {
10      wrapper: ({ children }) => <Provider store={store} >{children}</Provider>
11    });
12 
13    expect (result.current.isModalOpened).toBe(false);
14  });
15});

Please note that we render the children so that our hook can execute.

Summary

We’ve identified what the difficult parts of testing hooks are. Because we can only call them inside function components or other hooks, we need some utilities to test them. Instead of creating them ourselves, we can use the react-hooks-testing-library. In this article, we’ve learned how to test our hooks in more advanced cases, such as cases with asynchronous calls and Redux.

The react-hooks-testing-library is excellent for testing complex hooks. Also, it is very fitting for testing hooks that are highly reusable and not tied to a specific component. In other cases, when a hook is used in just one component, the creators of the react-hooks-testing-library express that it might be a better idea to test the component itself.