Test your React hooks with Vitest efficiently
Tags: testing, react, vitest, typescript
Published: 2023-06-13 03:39:26

---
title: "Test your React hooks with Vitest efficiently"
description: "Tips for planning and testing custom React hooks with Vitest and React Testing Library."
published: true
tags: ['testing', 'react', 'Vitest', 'typescript']
cover_image: https://res.cloudinary.com/mayashavin/image/upload/v1686602455/articles/testing/Testing3
canonical_url: "https://mayashavin.com/articles/test-react-hooks-with-vitest"
series: Unit testings with Vitest
---

_In this post, we will explore 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](#table-of-contents)
- [Setting up Vitest and JSDOM](#setting-up-vitest-and-jsdom)
- [Testing regular hook with React Testing Library](#testing-regular-hook-with-react-testing-library)
- [Testing hook with asynchronous logic](#testing-hook-with-asynchronous-logic)
  - [Spying and testing the external API call with spyOn and waitFor](#spying-and-testing-the-external-api-call-with-spyon-and-waitfor)
- [Summary](#summary)

## Setting up Vitest and JSDOM

Powered by Vite, [Vitest](https://vitest.dev/) 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:

```js
//...
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:

```json
"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](https://react-hooks-testing-library.com/):

```bash
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:

```typescript
//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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
/**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:

```typescript
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:

```typescript
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:

```typescript
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](https://twitter.com/MayaShavin) | [Facebook](https://www.facebook.com/mayashavin/)._

👉 _Learn about Vue with my new book [Learning Vue](https://www.oreilly.com/library/view/learning-vue/9781492098812/). The early release is available now!_

Like this post or find it helpful? Share it 👇🏼 😉