testingreactVitesttypescript

Test your React hooks with Vitest efficiently

Jun 13, 2023 ยท 8 min read
Buy Me A Coffee
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

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

Buy Me A Coffee