Testing React with Vitest and React Testing Library
Set up a fast, modern test suite for your React app using Vitest and React Testing Library — from first render to async interactions and mocks.

Testing React used to mean fighting Jest config. Vitest changes that — it shares your Vite config, understands ESM and TypeScript out of the box, and starts quickly. Combined with React Testing Library (RTL), you get a stack that tests your components the way users actually experience them.
Here's how to go from zero to a real test suite.
Installation
npm install -D vitest @vitest/ui jsdom \
@testing-library/react @testing-library/user-event \
@testing-library/jest-domAdd a setup file so every test file gets the RTL matchers automatically:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';Then configure Vitest. If you have a vite.config.ts, add a test block:
// vite.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});Importing defineConfig from vitest/config gives you types for the test block. Setting globals: true means you can call describe, it, expect, and vi without importing them — same ergonomics as Jest — and it also lets React Testing Library clean up automatically after each test. Add "types": ["vitest/globals"] to your tsconfig.json if you use TypeScript.
Add a script to package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui"
}
}Your First Component Test
Suppose you have a simple button counter:
// src/components/Counter.tsx
import { useState } from 'react';
export function Counter({ label = 'Count' }: { label?: string }) {
const [count, setCount] = useState(0);
return (
<div>
<p>{label}: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
);
}The test renders the component and makes assertions through the DOM — not through internal state:
// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('renders with the default label', () => {
render(<Counter />);
expect(screen.getByText(/count: 0/i)).toBeInTheDocument();
});
it('increments when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
it('accepts a custom label', () => {
render(<Counter label="Score" />);
expect(screen.getByText(/score: 0/i)).toBeInTheDocument();
});
});userEvent.setup() simulates real browser events including focus, pointer, and keyboard — more realistic than fireEvent. Always await user interactions.
Testing Async Behavior
Most real components fetch data. Here's a component that loads a user:
// src/components/UserCard.tsx
import { useEffect, useState } from 'react';
type User = { name: string; email: string };
export function UserCard({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading…</p>;
return <p>{user.name} — {user.email}</p>;
}Vitest has a built-in vi object for mocks. Mock fetch in the test:
// src/components/UserCard.test.tsx
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';
beforeEach(() => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
json: async () => ({ name: 'Alice', email: '[email protected]' }),
} as Response);
});
afterEach(() => vi.restoreAllMocks());
it('displays user data after loading', async () => {
render(<UserCard userId={1} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// findBy* keeps retrying until the element appears or it times out
expect(await screen.findByText(/alice/i)).toBeInTheDocument();
expect(screen.getByText(/alice@example\.com/i)).toBeInTheDocument();
});screen.findBy* queries return promises — they wait for the element to appear in the DOM. Use them for anything that involves async state updates.
Mocking Modules
Sometimes you need to swap out a whole module — a date utility, an analytics call, a custom hook:
// mock a named export
vi.mock('../lib/analytics', () => ({
track: vi.fn(),
}));
// assert it was called
import { track } from '../lib/analytics';
it('fires a page view event on mount', () => {
render(<HomePage />);
expect(track).toHaveBeenCalledWith('page_view', { path: '/' });
});Running Tests
npm test # watch mode — re-runs on file save
npm test -- --run # single pass (for CI)
npm run test:ui # browser-based UIVitest also ships coverage via @vitest/coverage-v8:
npm install -D @vitest/coverage-v8
npx vitest run --coverageWhat to Test (and What Not To)
Test behavior, not implementation:
- Does the button appear and fire the right action?
- Does the loading state show before data arrives?
- Does an error message appear on a failed fetch?
Be wary of snapshot tests for large component trees — they break on unrelated changes and tend to get updated without much thought. Skip testing third-party library internals. Focus on the paths your users actually walk.
With Vitest's fast startup and RTL keeping tests close to real usage, there's little reason to go without a test suite. Start with one test per component and grow from there.