- 1. JavaScript testing #1. Explaining types of tests. Basics of unit testing with Jest
- 2. JavaScript testing #2. Introducing Enzyme and testing React components
- 3. JavaScript testing #3. Testing props, the mount function and snapshot tests.
- 4. JavaScript testing #4. Mocking API calls and simulating React components interactions
- 5. JavaScript testing #5. Testing hooks with react-hooks-testing-library and Redux
- 6. JavaScript testing #6. Introduction to End-to-End testing with Cypress
- 7. JavaScript testing #7. Diving deeper into commands and selectors in Cypress
- 8. JavaScript testing #8. Integrating Cypress with Cucumber and Gherkin
- 9. JavaScript testing #9. Replacing Enzyme with React Testing Library
- 10. JavaScript testing #10. Advanced mocking with Jest and React Testing Library
- 11. JavaScript testing #11. Spying on functions. Pitfalls of not resetting Jest mocks
- 12. JavaScript testing #12. Testing downloaded files and file inputs with Cypress
- 13. JavaScript testing #13. Mocking a REST API with the Mock Service Worker
- 14. JavaScript testing #14. Mocking WebSockets using the mock-socket library
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { useState } from 'react'; function useModalManagement() { const [isModalOpened, setModalVisibility] = useState(false); function openModal() { setModalVisibility(true); } function closeModal() { setModalVisibility(false); } return { isModalOpened, openModal, closeModal } } |
The above hook does a straightforward job managing the modal state. Let’s start by testing if it does not throw any errors.
1 2 3 4 5 6 7 |
import useModalManagement from './useModalManagement'; describe('The useModalManagement hook', () => { it('should not throw an error', () => { useModalManagement(); }) }); |
1 2 3 |
FAIL useModalManagement.test.js The useModalManagement hook ✕ 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import React from 'react'; import { shallow } from 'enzyme'; function testHook(hook) { let output; function HookWrapper() { output = hook(); return ( <></> ); } shallow(<HookWrapper />); return output; } |
1 2 3 4 5 6 7 8 |
import useModalManagement from './useModalManagement'; import testHook from './testHook'; describe('The useModalManagement hook', () => { it('should not throw an error', () => { testHook(useModalManagement); }); }); |
1 2 3 |
PASS useModalManagement.test.js The useModalManagement hook ✓ 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:
1 2 3 4 5 6 7 8 |
import useModalManagement from './useModalManagement'; import { renderHook } from '@testing-library/react-hooks'; describe('The useModalManagement hook', () => { it('should not throw an error', () => { renderHook(() => useModalManagement()); }); }); |
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
1 2 3 4 5 6 7 8 9 |
import useModalManagement from './useModalManagement'; import { renderHook } from '@testing-library/react-hooks'; describe('The useModalManagement hook', () => { it('should describe a closed modal by default', () => { const { result } = renderHook(() => useModalManagement()); expect (result.current.isModalOpened).toBe(false); }); }); |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import useModalManagement from './useModalManagement'; import { renderHook, act } from '@testing-library/react-hooks'; describe('The useModalManagement hook', () => { describe('when the openModal function is called', () => { it('should describe an opened modal', () => { const { result } = renderHook(() => useModalManagement()); act(() => { result.current.openModal(); }); expect (result.current.isModalOpened).toBe(true); }); }) }); |
1 2 3 4 |
PASS useModalManagement.test.js The useModalManagement hook when the openModal function is called ✓ should describe an opened modal (3ms) |
Sometimes we need to pass arguments to the hook.
1 2 |
const { result } = renderHook(() => useModalManagement(true)); // 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import { useState } from 'react'; function useCommentsManagement() { const [comments, setComments] = useState([]); function fetchComments() { return fetch('https://jsonplaceholder.typicode.com/comments') .then(response => response.json()) .then((data) => { setComments(data); }) } return { comments, fetchComments } } |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { renderHook, act } from '@testing-library/react-hooks'; import useCommentsManagement from './useCommentsManagement'; describe('The useCommentsManagement hook', () => { describe('when the fetchComments function is called', () => { it('should update the state after a successful request', async () => { const { result, waitForNextUpdate } = renderHook(() => useCommentsManagement()); act(() => { result.current.fetchComments(); }); await waitForNextUpdate(); return expect(result.current.comments.length).not.toBe(0); }); }) }); |
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.
1 2 3 4 5 6 7 8 |
const { result, waitForValueToChange } = renderHook(() => useCommentsManagement()); act(() => { result.current.fetchComments(); }); await waitForValueToChange(() => { return result.current.comments; }); |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { useDispatch, useSelector } from 'react-redux'; import modalActions from './modalActions.js' function useModalManagement() { const isModalOpened = useSelector(state => state.modal.isOpened); const dispatch = useDispatch(); function openModal() { dispatch(modalActions.openModal()); } function closeModal() { dispatch(modalActions.closeModal()); } return { isModalOpened, openModal, closeModal } } |
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.
1 2 3 4 5 6 7 8 9 10 |
import useModalManagement from './useModalManagement'; import { renderHook } from '@testing-library/react-hooks'; describe('The useModalManagement hook', () => { it('should describe a closed modal by default', () => { const { result } = renderHook(() => useModalManagement()); expect (result.current.isModalOpened).toBe(false); }); }); |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import React from 'react'; import useModalManagement from './useModalManagement'; import { renderHook } from '@testing-library/react-hooks'; import store from '../../store'; import { Provider } from 'react-redux'; describe('The useModalManagement hook', () => { it('should describe a closed modal by default', () => { const { result } = renderHook(() => useModalManagement(), { wrapper: ({ children }) => <Provider store={store} >{children}</Provider> }); expect (result.current.isModalOpened).toBe(false); }); }); |
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.
Is testing using
@testing-library
still unit testing in your opinion? Please argue your answer.