Skip to content

How to test React custom hooks and components with Vitest

How to test React custom hooks and components with Vitest

Introduction

In this guide, we'll navigate through the process of testing React hooks and components using Vitest—a powerful JavaScript unit testing framework. Discover how Vitest simplifies testing setups, providing an optimal solution for both Vite-powered projects and beyond.

Vitest is a javascript unit testing framework that aims to position itself as the Test Runner of choice for Vite projects and as a solid alternative even for projects not using Vite. Vitest was built primarily for Vite-powered projects, to help reduce the complexity of setting up testing with other testing frameworks like Jest. Vitest uses the same configuration of your App (through vite.config.js), sharing a common transformation pipeline during dev, build, and test time.

Prerequisites

This article assumes a solid understanding of React and frontend unit testing. Familiarity with tools like React Testing Library and JSDOM will enhance your grasp of the testing process with Vitest.

Installation and configuration

Let’s see how we can use Vitest for testing React custom hooks and components. But first, we will need to create a new project with Vite! If you already have an existing project, you can skip this step.

npm create-vite@latest

Follow the prompts to create a new React project successfully. 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:

npm install -D vitest jsdom @testing-library/react

Once we have those packages installed, we need to configure the vite.config.js file to run tests. By default, some of the extra configs we need to set up Vitest are not available in the Vite config types, so we will need the vite.config.ts file to reference Vitest types by adding /// reference types=”vitest” /> at the top of the file.

Add the following code to the vite.config.ts

/// reference types=”vitest” />

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    root: 'src/',
  }
})

We set globals to true because, by default, Vitest does not provide global APIs for explicitness. So with this set to true, we can use keywords like describe, test and it without needing to import them. To get TypeScript working with the global APIs, add vitest/globals to the types field in your tsconfig.json.

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

The environment property tells Vitest which environment to run the test. We are using jsdom as the environment. The root property tells Vitest the root folder from where it should start looking for test files.

We should add a script for running the test in package.json

"scripts": {
    "test": "vitest"
}

With all that configured, we can now start writing unit tests for customs hooks and React components.

Writing test for custom hooks

Let’s write a test for a simple useCounter hook that takes an initial value and returns the value, an increment function and a decrement function.

// useCounter.ts
export const useCounter = (initialValue: number) => {
  const [value, setValue] = useState(initialValue);

  const increment = () => {
    setValue((prev) => prev + 1);
  };

  const decrement = () => {
    setValue((prev) => {
      if (prev === 0) return prev;
      return prev - 1;
    });
  };

  return { value, increment, decrement };
};

We can write a test to check the default return values of the hook for value as below:

import { renderHook } from '@testing-library/react';
import { useCounter } from '../hooks/useCounter';
import { expect } from 'vitest';

describe('useCounter', () => {
  it('should return a default value', () => {
    const initialValue = 0;
    const { result } = renderHook(() => useCounter(initialValue));

    expect(result.current.value).toBe(initialValue);
  });
});

To test if the hook works when we increment the value, we can use the act() method from @testing-library/react to simulate the increment function, as shown in the below test case:

describe('useCounter', () => {
  //…
  it('should increment the initial value once', () => {
    const initialValue = 1;
    const { result } = renderHook(() => useCounter(initialValue));

    expect(result.current.value).toBe(initialValue);

    act(() => result.current.increment());

    expect(result.current.value).toEqual(2);
  });
});

Kindly Note that you can't destructure the reactive properties of the result.current instance, or they will lose their reactivity.

Testing hooks with asynchronous logic

Now let’s test a more complex logic that contains asynchronous logic. Let’s write a useProducts hook that fetches data from an external api and return that value

import { useCallback, useEffect, useState } from 'react';

export const useProducts = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [products, setProducts] = useState([]);

  const fetchProducts = useCallback(async () => {
    try {
      setIsLoading(true);
      const response = await fetch('https://fakestoreapi.com/products');

      if (!response.ok) {
        throw new Error('Failed to fetch products);
      }

      const data = await response.json();
      setProducts(data);
      setIsLoading(false);
    } catch (err) {
        setIsLoading(false);
    }
  }, []);

  useEffect(() => {
    fetchProducts();
  }, []);

  return { isLoading, products };
};

Now, let’s see what the test looks like:

import { renderHook, waitFor } from '@testing-library/react';
import { useProducts } from '../hooks/useProducts';

describe('useProducts', () => {
  //Spy on the global fetch function
  const fetchSpy = vi.spyOn(window, '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([
            {
              id: 1,
              title: 'T-shirt',
              price: 109.95,
              Description: 'A nice t-shirt',
            },
          ])
        ),
    };
    fetchSpy.mockReturnValue(mockResolveValue as any);
  });

  //Run after all the tests
  afterAll(() => {
    fetchSpy.mockRestore();
  });

  it('should fetch products', async () => {
    const { result } = renderHook(() => useProducts());

    expect(result.current.isLoading).toEqual(true);
    await waitFor(() => expect(result.current.products.length).toEqual(1));
    expect(result.current.isLoading).toEqual(false);
  });
});

In the above example, we had to spy on the global fetch API, so that we can mock its return value. We wrapped that inside a beforeAll so that this runs before any test in this file. Then we added an afterAll method and called the mockRestore() to run after all test cases have been completed and return all mock implementations to their original function. 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 mocking the same function with different return values for different tests. We usually use mockClear() in beforeEach() or afterEach() methods to ensure our test is isolated completely. Then in our test case, we used a waitFor(), to wait for the return value to be resolved.

Writing test for components

Like Jest, Vitest provides assertion methods (matchers) to use with the expect methods for asserting values, but to test DOM elements easily, we will need to make use of custom matchers such as toBeInTheDocument() or toHaveTextContent(). Luckily the Vitest API is mostly compatible with the Jest API, making it possible to reuse many tools originally built for Jest. 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.

npm install -D @testing-library/jest-dom

After installing the jest-dom testing library package, create a file named vitest-setup.ts on the root of the project and import the following into the project to extend js-dom custom matchers:

import '@testing-library/jest-dom/vitest'

Since we are using typescript, we also need to include our setup file in our tsconfig.json:

// In tsconfig.json
  "include": [
    ...
    "./jest-setup.ts"
  ],

In vite.config.ts, we need to add the vitest-setup.ts file to the test.setupFiles field:

//vite.config.ts
    test: {
        //... 
        setupFiles: ['./vitest-setup.ts'],
    },

Now let’s test the Products.tsx component:

​​import { useProducts } from '../hooks/useProducts';

export const Products = () => {
  const { products } = useProducts();

  return (
    <div>
      <ul data-testid="product-list">
        {products.map((item) => (
          <li key={item.id} style={{ listStyle: 'none' }}>
            <div>
              <img src={item.image} width={80} height={120} />
              <h3>{item.title}</h3>
              <p>{item.description}</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

We start by spying and mocking the useProducts hook with vi.spyOn() method from Vitest:

import * as useProductsHooks from '../hooks/useProducts;

describe(“Products”, () => {
    const useProductsSpy = vi.spyOn(useProductsHooks, 'useProducts');
const items = [
        {
          id: 1,
          title: 'Fjallraven',
          description:
            'this is a test description',
          image: 'test.link.com',
        },
      ]
useProductsSpy.mockReturnValue({
        isLoading: false,
        products: items,
    });
});

Now, we render the Products component using the render method from @testing-library/react and assert that the component renders the list of products as expected and also the product has the title as follows:

describe(‘Products’, () => {
    /**... */
    it('should render the list of products and also find the title of the product', () => {
        //... 
     const { getByTestId, getByText } = render(
      <Products />
    );

    expect(getByTestId('product-list').children.length).toBe(1);
    expect(getByText('Fjallraven')).toBeInTheDocument();
    });
});

In the above code, we use the render method from @testing-library/react to render the component and this returns some useful methods we can use to extract information from the component like getByTestId and getByText. The getByTestId method will retrieve the element whose data-testid attribute value equals product-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. We also used the getByText method to find a text in the rendered component. We were able to call the toBeInTheDocument() because we extended the matchers to work with Vitest earlier. Here is what the full test looks like:

import { render } from '@testing-library/react';
import { expect } from 'vitest';
import { Products } from '../components/Products';
import * as useProductsHook  from '../hooks/useProducts';

describe('products', () => {
    const items = [
        {
          id: 1,
          title: 'Fjallraven',
          description:
            'this is a test description',
          image: 'test.link.com',
        },
      ]
    const useProductsSpy = vi.spyOn(useProductsHook, 'useProducts');
  it('should render the list of products and also find the title of the product', () => {
    useProductsSpy.mockReturnValue({
        isLoading: false,
        products: items,
    });
    const { getByTestId, getByText } = render(
      <Products />
    );

    expect(getByTestId('product-list').children.length).toBe(1);
    expect(getByText('Fjallraven')).toBeInTheDocument();
  });
});

Conclusion

In this article, we delved into the world of testing React hooks and components using Vitest, a versatile JavaScript unit testing framework. We walked through the installation and configuration process, ensuring compatibility with React, JSDOM, and React Testing Library. The comprehensive guide covered writing tests for custom hooks, including handling asynchronous logic, and testing React components, leveraging custom matchers for DOM assertions.

By adopting Vitest, developers can streamline the testing process for their React applications, whether powered by Vite or not. The framework's seamless integration with Vite projects simplifies the setup, reducing the complexities associated with other testing tools like Jest.