Test your React hooks with Vitest efficiently
This post explores how to use Vitest and React Testing Library to help unit test React hooks in a way that makes them easy to maintain and extend.
Table of Contents
- Table of Contents
- Setting up Vitest and JSDOM
- Testing regular hook with React Testing Library
- Testing hook with asynchronous logic
- Summary
Setting up Vitest and JSDOM
Powered by Vite, Vitest claims to be the "blazing fast unit test framework" for Vite projects. Vitest offers similar functionalities and syntax to Jest, with TypeScript/JSX supported out of the box while being faster in watching (HRM) and running your tests.
Despite being initially built for Vite-powered projects, we can also use Vitest with a proper configuration in non-Vite projects, such as Webpack.
To set up Vitest in a React project, we can install Vitest as a dev dependency using the following command
yarn -D vitest
We also install jsdom
(or any DOM standards implementation) as a dev dependency:
yarn add -D jsdom
Then in vitest.config.js
(or vite.config.js
for Vite projects), we add the following test
object to the configuration, with jsdom
as our DOM environment for testing:
//...
export default defineConfig({
test: {
environment: 'jsdom',
},
})
We can also set global: true
so that we don't have to explicitly import each method like the expect
, describe
, it
, or vi
instance from the vitest
package in every test file.
Once done with vitest.config.js
, we add a new command to the scripts
in package.json
file for running the unit tests as follows:
"scripts": {
"test:unit": "vitest --root src/"
}
In the above code, we set the root of our tests as the src/
folder. Alternatively, we can put it in vite.config.js
under the test.root
field. Both approaches work the same.
Next, let's add some tests.
Testing regular hook with React Testing Library
Vitest supports testing any JavaScript and TypeScript code. However, to test React component-specific features such as React hooks, we still need to create a wrapper for the desired hook and simulate the hook execution.
To do so, we can install and use Render hooks from React Testing Library:
yarn add -D @testing-library/react-hooks
Once done, we can use the renderHook
method from this package to render the desired hook. renderHook
will return an object instance containing a result
property and other useful instance methods such as unmount
and waitFor
. We then can access the hook's return value from the result.current
property.
For example, let's look at a useSearch
hook that receives an initial array of items and returns an object of reactive searchTerm, a filtered list of items, and a method to update the search term. Its implementation is as follows:
//hooks/useSearch.ts
import { useState, useMemo } from "react";
export const useSearch = (items: any[]) => {
const [searchTerm, setSearchTerm] = useState('');
const filteredItems = useMemo(
() => items.filter(
movie => movie.title.toLowerCase().includes(searchTerm.toLowerCase())
)
, [items, searchTerm]);
return {
searchTerm,
setSearchTerm,
filteredItems
};
}
We can write a test to check the default return values of the hook for searchTerm
and for filterItems
as below:
import { expect, it, describe } from "vitest";
import { renderHook } from '@testing-library/react-hooks'
import { useSearch } from "./useSearch"
describe('useSearch', () => {
it('should return a default search term and original items', () => {
const items = [{ title: 'Star Wars' }];
const { result } = renderHook(() => useSearch(items));
expect(result.current.searchTerm).toBe('');
expect(result.current.filteredItems).toEqual(items);
});
});
To test if the hook works when we update the searchTerm
, we can use the act()
method to simulate the setSearchTerm
execution, as shown in the below test case:
import { /**... */ act } from "vitest";
//...
it('should return a filtered list of items', () => {
const items = [
{ title: 'Star Wars' },
{ title: 'Starship Troopers' }
];
const { result } = renderHook(() => useSearch(items));
act(() => {
result.current.setSearchTerm('Wars');
});
expect(result.current.searchTerm).toBe('Wars');
expect(result.current.filteredItems).toEqual([{ title: 'Star Wars' }]);
});
//...
Note here you can't destructure the reactive properties of the result.current
instance, or they will lose their reactivity. For example, the below code won't work:
const { searchTerm } = result.current;
act(() => {
result.current.setSearchTerm('Wars');
});
expect(searchTerm).toBe('Wars'); // This won't work
Next, we can move on to testing a more complex useMovies
hook which contains asynchronous logic.
Testing hook with asynchronous logic
Let's look at the below example implementation of the useMovies
hook:
export const useMovies = ():{ movies: Movie[], isLoading: boolean, error: any } => {
const [movies, setMovies] = useState([]);
const fetchMovies = async () => {
try {
setIsLoading(true);
const response = await fetch("https://swapi.dev/api/films");
if (!response.ok) {
throw new Error("Failed to fetch movies");
}
const data = await response.json();
setMovies(data.results);
} catch (err) {
//do something
} finally {
//do something
}
};
useEffect(() => {
fetchMovies();
}, []);
return { movies }
}
In the above code, the hook runs the asynchronous call fetchMovies
on the first render using the synchronous effect hook useEffect
. This implementation leads to a problem when we try to test the hook, as the renderHook
method from @testing-library/react-hooks
doesn't wait for the asynchronous call to finish. Since we don't know when the fetching will resolve, we won't be able to assert the movies
value after it finishes.
To solve that, we can use the waitFor
method from @testing-library/react-hooks
, as in the following code:
/**useMovies.test.ts */
describe('useMovies', () => {
//...
it('should fetch movies', async () => {
const { result, waitFor } = renderHook(() => useMovies());
await waitFor(() => {
expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
});
});
//...
});
waitFor
accepts a callback and returns a Promise that resolves when the callback executes successfully. In the above code, we wait for the movies
value to equal the expected value. Optionally, we can pass an object as the second argument to waitFor
to configure the timeout and interval of the polling. For example, we can set the timeout to 1000ms as below:
await waitFor(() => {
expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
}, {
timeout: 1000
});
By doing so, if the movies
value doesn't equal the expected value after 1000ms, the test will fail.
Spying and testing the external API call with spyOn and waitFor
In the previous test for useMovies
, we are fetching the external data using the fetch
API, which is not ideal for unit testing. Instead, we should use the vi.spyOn
method (with vi
as the Vitest instance) to spy on the global.fetch
method and mock its implementation to return a fake response, as in the following code:
import { /**... */ vi, beforeAll } from "vitest";
describe('useMovies', () => {
//Spy on the global fetch function
const fetchSpy = vi.spyOn(global, 'fetch');
//Run before all the tests
beforeAll(() => {
//Mock the return value of the global fetch function
const mockResolveValue = {
ok: true,
json: () => new Promise((resolve) => resolve({
results: [{ title: 'Star Wars' }]
}))
};
fetchSpy.mockReturnValue(mockResolveValue as any);
});
it('should fetch movies', async () => { /**... */ }
});
In the above code, we mock the return value of fetchSpy
using its mockReturnValue()
method with the value we created. With this implementation, we can run our test without triggering the real API call, reducing the chance of the test failing due to external factors.
And since we mock the fetch
method's return value, we need to restore its original implementation after the tests finish, using the mockRestore
method from Vitest, as in the following code:
import { /**... */ vi, beforeAll, afterAll } from "vitest";
describe('useMovies', () => {
const fetchSpy = vi.spyOn(global, 'fetch');
/**... */
//Run after all the tests
afterAll(() => {
fetchSpy.mockRestore();
});
});
Additionally, we can also use the mockClear()
method to clear all the mock's information, such as the number of calls and the mock's results. This method is handy when asserting the mock's calls or mocks the same function with different return values for different tests. We usually use mockClear()
in beforeEach
or afterEach
method to ensure our test is isolated completely.
And that's it. You can now go ahead and experiment testing your custom hooks efficiently.
Note
Unfortunately, currently @testing-library/react-hooks
doesn't work with React 18. The package is under the migrating process into the official package of React Testing Library (@testing-library/react
). Some features, such as waitForNextUpdate
will no longer be available.
Summary
In this article, we experiment how to test custom hooks using the React Hooks Testing Library and Vitest package. We also learned how to test hooks with asynchronous logic using the waitFor
method, and how to spy on external API calls using the vi.spyOn
method from Vitest.
What's next? Once our hooks are well tested, we can move on to the next level of testing - testing React components using the React Testing Library and Vitest package. So stay tuned!
๐ If you'd like to catch up with me sometimes, follow me on Twitter | Facebook.
๐ Learn about Vue with my new book Learning Vue. The early release is available now!
Like this post or find it helpful? Share it ๐๐ผ ๐