React component testing with Vitest efficiently

Jun 21, 2023 ยท 7 min read
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

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:

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 event change 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 ๐Ÿ‘‡๐Ÿผ ๐Ÿ˜‰