Test our Custom Hooks
Testing a custom hook might sound intimidating at first, but there are actually some repeatable steps that take a lot of the guesswork and mystery out of it.
Test Hardware Handler's custom hooks#
Integration testing React Custom Hooks is a little bit different than testing functional components because these hooks don't have to be tied to a specific component. But not to worry, React Testing Library's @testing-library/react-hooks
package will help us handle them.
We'll go through the hows and whys of testing custom hooks in our React application in this lesson, so you'll be ready to tackle any hooks you may come across.
The useProducts.js Hook is a good hook to start with#
Since our custom hooks aren't tied tightly to a particular component (the whole point of them is decoupled, reusable functionality), and they live in their own separate hooks/
folder inside of our app, to test them, I like to create a test/
folder to group all the hook tests together.
Make a new test folder and file for our custom hooks#
To get us going, let's make a new test/
folder inside of the hooks/
folder and create our first test file.
Keep it explicit about exactly which custom hook is being tested by naming the new file something like: useProducts.test.js
.
From here, we can get started on our tests for the useProducts
Hook.
Set up our test file#
Unlike our other test lesson, which used the @testing-library/react
library for methods like render
and fireEvent
, this custom hook is going to use the @testing-library/react-hooks
library.
Just like the first testing library, this hooks library aims to provide a testing experience as close as possible to natively using a custom hook from within a real component.
Define a render hook helper function
The key method we need from this hooks library is called renderHook
.
To make use of it, we'll import it at the top of our file, then create a reusable helper function to render our hook without having to copy all the required boilerplate for each of our tests.
import { renderHook } from '@testing-library/react-hooks';
import { useProducts } from '../useProducts';
const renderUseProductsHook = async () => {
const { result, waitForNextUpdate } = renderHook(() => useProducts());
await waitForNextUpdate();
return { result, waitForNextUpdate };
};
Okay, let's talk about what's happening up above in this renderUseProductsHook
function.
When we're rendering a function component with JSX, we can just pass any props and mock any data that component needs. For custom hooks, however, we have to create and render the hook to test and mock any variables or functions being passed into the hook (there are none in the case of the useProducts
Hook).
Once that's done, we'll call the renderHook
method we imported from the testing library and create an instance of the hook to test (useProducts
) inside of that method. It's in here that we'd pass any previously defined mocks or variables required by the hook.
From the renderHook
function, we destructure two objects: the result
and the waitForNextUpdate
function.
waitForNextUpdate
is a function that returns a Promise that resolves the next time the hook renders. We need this function to run after the first renderHook
function to wait for React to update. This is because React Testing Library can't wait for the DOM to update because there is no DOM when testing custom hooks.
Finally, the result
and the waitForNextUpdate
variables are returned from the function because we need them for our actual tests.
By defining all of this within a helper function, we can call this function from all of our tests and save ourselves the duplicate code to make these hooks.
Time to move on: let's put this function into practice.
Mock the product API
Another piece of setup we need to do for this hook test is mocking the productApi
function that the hook calls: the getAllProducts
function.
Since our hook will call this function every time it's run, it makes sense to wrap our data in a beforeEach
function.
First, we'll write our generic describe
block for these tests to live within:
describe('the useProducts Hook', () => {
Then we'll set up our mock where we spy on the productApi
and the data we expect to be returned from the mock.
const getAllProductsMock = jest.spyOn(productApi, 'getAllProducts');
beforeEach(() => {
getAllProductsMock.mockResolvedValue([
{
brand: 'Gnome Gardening',
departmentId: 45,
description: 'A trowel above all others.',
id: 1,
name: 'Polka Dot Trowel',
retailPrice: 999,
},
{
brand: 'Gnome Gardening',
departmentId: 45,
description:
'Protect yourself from sun burn while gardening with a wide brimmed, lightweight sun hat.',
id: 2,
name: 'Rose Sun Hat',
retailPrice: 2495,
},
{
brand: 'SL',
departmentId: 56,
description:
'This fridge keeps your food at the perfect temperature guaranteed.',
id: 3,
name: 'Stainless Steel Refrigerator',
retailPrice: 229900,
},
{
brand: 'Swirl Pool',
departmentId: 56,
description:
'Clothes have never been so clean, and a washer has never looked so stylish cleaning them.',
id: 4,
name: 'Matte Black Connected Washing Machine',
retailPrice: 45050,
},
]);
And if we set up a beforeEach
, we need to set up an afterEach
to clean up and reset our mocks for each test.
afterEach(() => {
jest.resetAllMocks();
});
I think now we can get to testing our hook — finally!
Test that products and brand filters return from the hook
Our hook returns three separate pieces of info, but two of the pieces — the products and the filters by brand name — are tied together. As long as the product API returns data, both of those pieces of information can be determined from it. So it makes sense to me to test for both of those pieces of info together.
Here's how we'll approach these tests: we'll render our custom hook using the helper function we defined and destructure the result
object from it.
From the result
object, we'll look for the products
and filtersByBrand
properties and compare their values against our mocked data set that we composed in our previous lesson in the mockDataSet.json
file.
Not only will we check the length of both variables to ensure they match our mocked data, but we'll also use the Jest method toStrictEqual
to test that the objects have the same values and structure.
If we want to, we can even check that the error
still has the value of false
in this test.
Here's what my test looks like.
it('should return a list of of products and filters by brand when the product API returns info', async () => {
const { result } = await renderUseProductsHook();
const { products, filtersByBrand, error } = result.current;
expect(products.length).toEqual(dataSet.products.length);
expect(filtersByBrand.length).toEqual(dataSet.filtersByBrand.length);
expect(products).toStrictEqual(dataSet.products);
expect(filtersByBrand).toStrictEqual(dataSet.filtersByBrand);
expect(error).not.toBeTruthy();
});
As you can see, once the result
object is rendered from our custom hook, making checks against the data returned is pretty easy and similar to things we might check in a typical functional component.
Ready to move on to when our productApi
call throws an error?
Test if the product API fails, the hook returns an error
For this test, we'll override our expected getAllProductsMock
with a custom one that throws the error from the server instead of returning data.
Once more, we'll then render the hook, extract the objects from the result.current
object, and check the values are what we expect when this getAllProducts
API call fails.
Here's how I'd write this second test.
it('should return an error if the product API fails to return data', async () => {
getAllProductsMock.mockResolvedValue(FETCH_PRODUCT_DATA_ERROR);
const { result } = await renderUseProductsHook();
const { products, filtersByBrand, error } = result.current;
expect(products).toEqual(FETCH_PRODUCT_DATA_ERROR);
expect(filtersByBrand).toEqual([]);
expect(error).toBeTruthy();
});
That looks pretty sensible, right?
Run code coverage for our new hook test#
As a gut check, let's run our new test file and check the code coverage for this custom hook now.
cd client/ && yarn coverage
Here's the terminal's code coverage output.
This page is a preview of The newline Guide to Modernizing an Enterprise React App