Stackvora.tech

Vitest + React Testing Library

Unit and integration testing are crucial for maintaining code quality and ensuring application reliability. For modern React applications, a robust…

Unit and integration testing are crucial for maintaining code quality and ensuring application reliability. For modern React applications, a robust testing setup typically involves a fast test runner and a library focused on user-centric testing. This article details the integration of Vitest as a test runner and React Testing Library (RTL) for component testing, providing a streamlined and efficient testing workflow.

Vitest, a next-generation test framework, offers exceptional speed and an API compatible with Jest, making it a natural fit for projects already familiar with Jest or those seeking performance improvements. React Testing Library, on the other hand, emphasizes testing components as users would interact with them, promoting accessibility and robust tests less coupled to implementation details.

Setting up Vitest and React Testing Library

To begin, create a new React project or navigate to an existing one. We'll assume a project bootstrapped with Vite, as Vitest integrates seamlessly with it. If you're using Create React App or a different bundler, you might need additional configuration steps not covered here.

Installation

Install Vitest, React Testing Library, and its DOM extension:

npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
  • vitest: The test runner and assertion library.
  • @testing-library/react: React-specific utilities for RTL.
  • @testing-library/jest-dom: Custom Jest matchers (compatible with Vitest's expect) for DOM assertions.
  • jsdom: A JavaScript implementation of the DOM, necessary for testing React components in a Node.js environment without a browser.

Vitest Configuration

Create a vitest.config.js (or .ts) file in your project root. This configuration tells Vitest how to run your tests.

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom', // Use JSDOM for browser-like environment
    globals: true, // Expose Vitest globals (expect, describe, it)
    setupFiles: './src/setupTests.js', // Global setup file for RTL
    css: false, // Disable CSS processing for tests if not needed
    // You can also add other configurations like coverage, watch settings, etc.
    // coverage: {
    //   provider: 'v8', // or 'istanbul'
    // },
  },
});

Key configuration options:

  • plugins: [react()]: Ensures that Vite's React plugin is used, allowing JSX transformation in test files.
  • environment: 'jsdom': Specifies the testing environment. jsdom mimics a browser environment, allowing React components to render.
  • globals: true: Makes expect, describe, it, etc., globally available without explicit imports in every test file.
  • setupFiles: './src/setupTests.js': Points to a file that runs before all tests. This is where we configure @testing-library/jest-dom.

Global Setup File

Create the src/setupTests.js file as specified in your Vitest config:

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

This import extends Vitest's expect with additional matchers from @testing-library/jest-dom, such as .toBeInTheDocument(), .toHaveClass(), etc. These matchers are incredibly useful for making assertions about the DOM.

Writing Your First Test

Let's create a simple Button component and write a test for it.

The Button Component (src/components/Button.jsx)

import React from 'react';

const Button = ({ onClick, children }) => {
  return (
    <button type="button" onClick={onClick}>
      {children}
    </button>
  );
};

export default Button;

The Test File (src/components/Button.test.jsx)

It's common practice to place test files alongside the component they test, often with a .test.js or .spec.js suffix.

import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest'; // Explicit imports for clarity, though globals: true makes them optional
import userEvent from '@testing-library/user-event'; // For simulating user interactions

import Button from './Button';

describe('Button', () => {
  it('renders correctly with children', () => {
    render(<Button>Click Me</Button>);
    // Use screen.getByRole to find elements by their ARIA role,
    // which is a user-centric way to query the DOM.
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick handler when clicked', async () => {
    const handleClick = vi.fn(); // Create a Vitest mock function
    render(<Button onClick={handleClick}>Submit</Button>);

    const buttonElement = screen.getByRole('button', { name: /submit/i });

    // Simulate a user click event
    await userEvent.click(buttonElement);

    // Assert that the mock function was called
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('does not call onClick handler when disabled (if implemented)', async () => {
    // For this test to pass, Button component would need to support a 'disabled' prop
    // Example: <button type="button" onClick={onClick} disabled={disabled}>
    const handleClick = vi.fn();
    render(<Button onClick={handleClick} disabled>Disabled</Button>); // Assume Button accepts 'disabled' prop

    const buttonElement = screen.getByRole('button', { name: /disabled/i });
    expect(buttonElement).toBeDisabled(); // From @testing-library/jest-dom

    await userEvent.click(buttonElement);

    expect(handleClick).not.toHaveBeenCalled();
  });
});

Running Tests

Add a script to your package.json for convenience:

{
  "name": "my-react-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest",
    "test:watch": "vitest --watch"
  },
  // ... other dependencies
}

Now you can run your tests:

  • npm test: Runs all tests once and exits.
  • npm run test:watch: Runs tests in watch mode, rerunning them whenever relevant files change. This is highly recommended during development.

React Testing Library Principles and Best Practices

RTL encourages tests that mimic how users interact with your components, rather than focusing on internal implementation details. This leads to more robust tests that break less often when refactoring.

Query Priorities

RTL provides several methods to query elements. Use them in this order of preference:

  1. getByRole: Best for accessibility. Most HTML elements have an implicit role (e.g., <button> has role "button", <input type="checkbox"> has role "checkbox").
  2. getByLabelText: For form fields. It finds the label and then the associated input.
  3. getByPlaceholderText: For inputs with a placeholder.
  4. getByText: For elements displaying text content.
  5. getByDisplayValue: For inputs, textareas, or selects that currently display a specific value.
  6. getByAltText: For images, areas, or inputs (type="image").
  7. getByTitle: For elements with a title attribute.
  8. getByTestId: As a last resort, for elements that cannot be queried by semantic means. Add a data-testid attribute.

Example using getByRole and getByText:

// Assuming a Heading component: <h1>Welcome</h1>
render(<Heading>Welcome</Heading>);
expect(screen.getByRole('heading', { level: 1, name: /welcome/i })).toBeInTheDocument();

// Assuming a Paragraph component: <p>Some descriptive text.</p>
render(<Paragraph>Some descriptive text.</Paragraph>);
expect(screen.getByText(/descriptive text/i)).toBeInTheDocument();

Asynchronous Operations

React applications often involve asynchronous operations like data fetching. RTL provides utilities to handle these:

  • findBy* queries: These are asynchronous versions of getBy*. They return a Promise and will wait for an element to appear in the DOM.
  • waitFor: Explicitly waits for an assertion to pass.
  • waitForElementToBeRemoved: Waits for an element to disappear from the DOM.
import { render, screen, waitFor } from '@testing-library/react';
// ... other imports

it('displays loaded data after fetch', async () => {
  // Mock API call
  vi.spyOn(window, 'fetch').mockResolvedValueOnce({
    json: () => Promise.resolve({ data: 'Mocked Data' }),
  });

  render(<DataFetcherComponent />); // Assume this component fetches data and displays it

  // findByText will wait for the element to appear
  expect(await screen.findByText(/mocked data/i)).toBeInTheDocument();

  // Clean up the mock
  vi.restoreAllMocks();
});

Mocking Dependencies with Vitest

When testing components, you often need to isolate them from external dependencies like API calls, routing, or complex contexts. Vitest provides powerful mocking capabilities.

Mocking Modules

To mock an entire module:

// Assuming you have an api.js: export const fetchData = () => ...
// In your test file:
import { vi } from 'vitest';

vi.mock('../src/api', () => ({
  fetchData: vi.fn(() => Promise.resolve('Mocked API Response')),
  postData: vi.fn(),
}));

import { fetchData } from '../src/api'; // Import after mocking

describe('Component with API call', () => {
  it('calls fetchData on mount', async () => {
    render(<MyComponentThatFetchesData />);
    expect(fetchData).toHaveBeenCalledTimes(1);
    expect(await screen.findByText(/mocked api response/i)).toBeInTheDocument();
  });
});

Mocking Functions/Objects

For individual functions or methods on an object, vi.fn() and vi.spyOn() are invaluable.

// Mocking a standalone function
const myFunction = vi.fn(() => 'mocked value');
expect(myFunction()).toBe('mocked value');
expect(myFunction).toHaveBeenCalledTimes(1);

// Mocking a method on an object
const myObject = {
  doSomething: () => 'real value',
};
const spy = vi.spyOn(myObject, 'doSomething');
expect(myObject.doSomething()).toBe('real value'); // The original implementation is called
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore(); // Restore original implementation

const mockImplementation = vi.spyOn(myObject, 'doSomething').mockImplementation(() => 'mocked value');
expect(myObject.doSomething()).toBe('mocked value');
mockImplementation.mockRestore();

Common Pitfalls and Troubleshooting

  • Not cleaning up after tests: Ensure you clean up any global side effects or DOM changes. RTL's cleanup function (imported from @testing-library/react) is automatically called after each test when using jsdom environment, but be mindful of other global states or mocks.
  • Over-reliance on data-testid: While useful for difficult-to-query elements, prioritizing semantic queries like getByRole leads to more resilient and accessible tests.
  • Testing implementation details: Avoid testing internal component state or methods unless absolutely necessary. Focus on inputs (props, user events) and outputs (rendered DOM, triggered callbacks).
  • Forgetting await for async queries: findBy* queries and userEvent interactions (e.g., userEvent.click) return Promises. Always await them.
  • Missing jsdom environment: If you get errors about document or window not being defined, double-check your vitest.config.js to ensure environment: 'jsdom' is set correctly.
  • Incorrect setupFiles path: Verify the path to setupTests.js in your Vitest config is correct and the file correctly imports @testing-library/jest-dom/vitest.
  • Memory leaks in watch mode: If Vitest's watch mode becomes slow over time, ensure your mocks are properly reset between tests (e.g., using vi.clearAllMocks() or vi.restoreAllMocks() in an afterEach hook if you're not using module mocks).

Back to the knowledge base · Ask the AI assistant