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.jsdommimics a browser environment, allowing React components to render.globals: true: Makesexpect,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:
getByRole: Best for accessibility. Most HTML elements have an implicit role (e.g.,<button>has role "button",<input type="checkbox">has role "checkbox").getByLabelText: For form fields. It finds the label and then the associated input.getByPlaceholderText: For inputs with a placeholder.getByText: For elements displaying text content.getByDisplayValue: For inputs, textareas, or selects that currently display a specific value.getByAltText: For images, areas, or inputs (type="image").getByTitle: For elements with atitleattribute.getByTestId: As a last resort, for elements that cannot be queried by semantic means. Add adata-testidattribute.
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 ofgetBy*. 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
cleanupfunction (imported from@testing-library/react) is automatically called after each test when usingjsdomenvironment, but be mindful of other global states or mocks. - Over-reliance on
data-testid: While useful for difficult-to-query elements, prioritizing semantic queries likegetByRoleleads 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
awaitfor async queries:findBy*queries anduserEventinteractions (e.g.,userEvent.click) return Promises. Alwaysawaitthem. - Missing
jsdomenvironment: If you get errors aboutdocumentorwindownot being defined, double-check yourvitest.config.jsto ensureenvironment: 'jsdom'is set correctly. - Incorrect
setupFilespath: Verify the path tosetupTests.jsin 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()orvi.restoreAllMocks()in anafterEachhook if you're not using module mocks).