React component testing with Vitest efficiently
The previous post explored how to test React Hooks as a standalone unit with Vitest and React Testing Library. In this post, we will continue learning how to leverage to unit test React components in a maintainable and extendable way.
Table of Contents
- Table of Contents
- Prerequisites
- Extending Vitest's expect method
- Testing the Movies component with mocks
- Testing the search input's functionality
- Cleaning up the mocks after each test
- Cleaning up the DOM after each test
- Summary
Prerequisites
You should have a React project set up and running. The recommended way is to initialize your project with Vite as its bundle management tool, using the command npm create vite@latest
.
For testing, we need the following dependencies installed:
- Vitest as the unit testing framework
- JSDOM as the DOM environment for running our tests
- React Testing Library as the React testing utilities
To do so, we run the following command:
yarn add -D vitest jsdom @testing-library/react
In vitest.config.js
(or vite.config.js
for Vite projects), we add the following test
object:
//...
export default defineConfig({
test: {
global: true,
environment: 'jsdom',
},
})
We also add a new test:unit
command to the package.json
file for running the unit tests as follows:
"scripts": {
"test:unit": "vitest --root src/",
}
Next, we will do an extra setup to get Vitest to assert DOM elements.
Extending Vitest's expect method
Vitest offers essential assertion methods to use with expect
for asserting values. However, it doesn't have the assertion methods for DOM elements such as toBeInTheDocument()
or toHaveTextContent()
. For such methods, we can install the @testing-library/jest-dom
package and extend the expect
method from Vitest to include the assertion methods in matchers
from this package.
To do so, we will create a setupTest.js
file in the root directory of our project and add the following code:
/**setupTest.js */
import { expect } from 'vitest';
import matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
In vitest.config.js
, we can add the setupTest.js
file to the test.setupFiles
field:
//vitest.config.js
/**... */
test: {
/**... */
setupFiles: './setupTest.js',
},
/**... */
And with this setup, expect()
will now have all the DOM assertion methods needed for testing React components.
Let's look at how to test React components with Vitest and React Testing Library.
Testing the Movies component with mocks
For this section, we will look at a simple component - Movies
- that displays a list of movies with the following features:
- The component fetches the movies list from an external source.
- Users can search for a movie by title.
An example implementation for the Movies
component is as follows:
export const Movies = () => {
const { movies } = useMovies();
const {searchTerm, setSearchTerm, filteredItems: filteredMovies} = useSearch(movies);
return (
<section>
<div>
<label htmlFor="search">Search</label>
<input
type="search" id="search" value={searchTerm}
data-testid="search-input-field"
onChange={event => setSearchTerm(event.target.value)}
/>
</div>
<ul data-testid="movies-list">
{filteredMovies.map((movie, index) => (
<li key={index}>
<article>
<h2>{movie.title}</h2>
<p>Release on: {movie.release_date}</p>
<p>Directed by: {movie.director}</p>
<p>{movie.opening_crawl}</p>
</article>
</li>
))}
</ul>
</section>
)
};
We will start by spying and mocking the useMovies
and useSearch
hooks with vi.spyOn()
method from Vitest (with vi
as the Vitest instance), as in the following code:
import * as useMoviesHooks from '../hooks/useMovies';
import * as useSearchHooks from '../hooks/useSearch';
describe('Movies', () => {
const useMoviesSpy = vi.spyOn(useMoviesHooks, 'useMovies');
const useSearchSpy = vi.spyOn(useSearchHooks, 'useSearch');
});
We will mock their return values using mockReturnValue
method, as follows:
describe('Movies', () => {
/**... */
it('should render the app', () => {
const items = [{
title: 'Star Wars',
release_date: '1977-05-25',
director: 'George Lucas',
opening_crawl: 'It is a period of civil war.'
}];
useMoviesSpy.mockReturnValue({
movies: items,
});
useSearchSpy.mockReturnValue({
searchTerm: '',
setSearchTerm: vi.fn(),
filteredItems: items
});
/**... */
});
})
Then, we will render the Movies
component using render
method from @testing-library/react
and assert that the component renders the list of movies as expected, as follows:
import { describe, it, expect, vi } from 'vitest';
import { Movies } from './Movies';
import { render } from '@testing-library/react';
describe('Movies', () => {
/**... */
it('should render the the list of movies', () => {
/**... */
const { getByTestId } = render(<Movies />);
expect(
getByTestId('movies-list').children.length
).toBe(items.length);
});
})
The getByTestId
method will retrieve the element whose data-testid
attribute value equals movies-list
,and we can then assert its children to equal the length of our mocked items
array.
Using data-testid
attribute values is a good practice for identifying a DOM element for testing purposes and avoiding affecting the component's implementation in production and tests.
Next, we will test the integration of the search hook in Movies
.
Testing the search input's functionality
We start by mocking only the useMovies
hook to return a set of movies, as follows:
it('should change the filtered items when the search term changes', () => {
const items = [
{ title: 'Star Wars' },
{ title: 'Star Trek' },
{ title: 'Starship Troopers' }
];
useMoviesSpy.mockReturnValue({
movies: items,
isLoading: false,
error: null
});
});
We render the Movies
component and use the getByTestId
method to retrieve the search input field using the data-testid
:
it('should change the filtered items when the search term changes', () => {
/**... */
const { getByTestId } = render(<Movies />);
const searchInput = getByTestId('search-input-field');
});
To test the search functionality in the UI of Movies
, we will use the two following methods from @testing-library/react
:
fireEvent.change()
- to simulate a user eventchange
on the search input field.act()
- to wrap around the execution of user event simulation and ensure all the updates apply to the DOM before proceeding with the assertion on the number of items displayed on the UI.
import { fireEvent, render, act } from '@testing-library/react';
it('should change the filtered items when the search term changes', () => {
/**... */
act(() => {
fireEvent.change(searchInput, { target: { value: 'Wars' } });
})
expect(
getByTestId('movies-list').children.length
).toBe(1);
});
With this, we have tested the user interaction with the search input field and the component's response to the user input.
However, if you run the test after the previous test, this test will fail since the last mock value of useSearch
is still in effect. We must clean and restore the original implementation after each test to ensure the simulated values are isolated per test case. We will do so in the next section.
Cleaning up the mocks after each test
To clean up any mocked value of each hook we spied on, we will trigger mockClear()
as follows:
afterEach(() => {
useMoviesSpy.mockClear();
useSearchSpy.mockClear();
});
With this code, after each test run, Vitest will clear any existing mocked value or implementation of the spied hooks, ready for the next test run. Alternatively, we can use mockRestore()
to restore the non-mocked implementation.
Next, we will apply a similar approach, cleaning the DOM for each test run but for all the test suites.
Cleaning up the DOM after each test
In setupTest.js
, we can run the cleanup
method from @testing-library/react
to clean up the DOM after each test, using the afterEach
method from Vitest, as seen below:
/**setupTest.js */
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
/**... */
afterEach(() => {
cleanup();
});
By doing so, we can ensure that the DOM is clean before each test run, applying it to all the test suites.
Summary
This article shows us how to test React components using the React Testing Library and Vitest package with the proper mock method and appropriate testing approach.
We can extend the testing from the example tests to cover more scenarios, such as testing a loading state or error state when loading the movies or adding more filter options for the search input. With the right component and hook structures, we can create our testing system in an organized and extendable way.
๐ If you'd like to catch up with me sometimes, follow me on Twitter | Facebook. | Buy me a coffee
๐ Learn about Vue with my new book Learning Vue. The early release is available now!
Like this post or find it helpful? Share it ๐๐ผ ๐