React components need tests too โ to verify that they render the right output for given props, respond correctly to user interactions, and handle edge cases gracefully. Vitest is the natural test runner for Vite projects โ it is faster than Jest, shares the Vite config including path aliases, and has an identical API. React Testing Library (RTL) provides utilities for rendering components and querying the DOM the way a real user would โ by visible text, labels, and roles rather than implementation details like CSS classes or component state. Together they let you test components confidently without ever relying on internal implementation details.
Setup โ Vitest and React Testing Library
cd client
npm install --save-dev vitest @testing-library/react @testing-library/user-event \
@testing-library/jest-dom jsdom msw
// client/vite.config.js โ add test configuration
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
test: {
globals: true, // no need to import describe, test, expect
environment: 'jsdom', // simulate browser DOM
setupFiles: ['./src/test/setup.ts'],
css: false, // skip CSS processing in tests
},
});
// client/src/test/setup.ts
import '@testing-library/jest-dom'; // adds .toBeInTheDocument(), .toHaveValue() etc.
getByRole('button', { name: /submit/i }) instead of getByTestId('submit-btn') or container.querySelector('.btn-primary'). Tests written this way remain valid even if you rename a CSS class or refactor the component’s internal state management.screen queries from @testing-library/react directly โ they query the globally rendered component tree without needing to capture a return value from render(). The most useful query families are getBy* (throws if not found โ good for elements that must exist), queryBy* (returns null if not found โ good for asserting absence), and findBy* (async โ waits up to 1000ms โ good for elements that appear after state updates).Testing a Presentational Component
// src/components/posts/__tests__/PostCard.test.jsx
import { describe, test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom'; // PostCard uses Link
import PostCard from '../PostCard';
const mockPost = {
_id: '64a1f2b3c8e4d5f6a7b8c9d0',
title: 'Getting Started with MERN',
excerpt: 'Learn how to build a full-stack application',
viewCount: 142,
featured: false,
createdAt: '2025-01-15T00:00:00.000Z',
tags: ['mern', 'react'],
author: { _id: 'author-id', name: 'Jane Smith', avatar: null },
};
const renderPostCard = (props = {}) => render(
<BrowserRouter>
<PostCard post={{ ...mockPost, ...props.post }} {...props} />
</BrowserRouter>
);
describe('PostCard', () => {
test('renders the post title', () => {
renderPostCard();
expect(screen.getByText('Getting Started with MERN')).toBeInTheDocument();
});
test('renders the author name', () => {
renderPostCard();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
test('renders the view count', () => {
renderPostCard();
expect(screen.getByText(/142/)).toBeInTheDocument();
});
test('shows Featured badge when post is featured', () => {
renderPostCard({ post: { ...mockPost, featured: true } });
expect(screen.getByText('Featured')).toBeInTheDocument();
});
test('does not show Featured badge for regular posts', () => {
renderPostCard();
expect(screen.queryByText('Featured')).not.toBeInTheDocument();
});
test('does not show delete button for non-owners', () => {
renderPostCard({ isOwner: false });
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});
test('shows delete button for the post owner', () => {
renderPostCard({ isOwner: true, onDelete: vi.fn() });
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
test('calls onDelete with the post ID when delete is clicked', async () => {
const { userEvent } = await import('@testing-library/user-event');
const user = userEvent.setup();
const onDelete = vi.fn();
renderPostCard({ isOwner: true, onDelete });
await user.click(screen.getByRole('button', { name: /delete/i }));
expect(onDelete).toHaveBeenCalledWith(mockPost._id);
});
});
Mocking API Calls with MSW
// src/test/handlers.js โ MSW request handlers
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/posts', () => {
return HttpResponse.json({
success: true,
data: [
{ _id: '1', title: 'Post 1', author: { name: 'Alice' }, tags: [], viewCount: 0 },
{ _id: '2', title: 'Post 2', author: { name: 'Bob' }, tags: [], viewCount: 5 },
],
total: 2,
});
}),
http.post('/api/auth/login', () => {
return HttpResponse.json({
success: true,
token: 'mock-jwt-token',
data: { _id: 'user-1', name: 'Test User', email: 'test@example.com', role: 'user' },
});
}),
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
if (body.password === 'wrongpassword') {
return HttpResponse.json({ success: false, message: 'Invalid credentials' }, { status: 401 });
}
return HttpResponse.json({ success: true, token: 'mock-token', data: { ... } });
}),
];
// src/test/setup.ts โ start MSW before tests
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // reset overrides between tests
afterAll(() => server.close());
Common Mistakes
Mistake 1 โ Using getByTestId instead of semantic queries
โ Wrong โ querying by test ID that ties the test to implementation:
screen.getByTestId('delete-btn'); // breaks if you rename data-testid
โ Correct โ query by accessible role or text:
screen.getByRole('button', { name: /delete/i }); // โ matches user intent
Mistake 2 โ Not wrapping Router-dependent components in BrowserRouter
โ Wrong โ component uses Link but test renders without Router:
render(<PostCard post={post} />);
// Error: useHref() may be used only in the context of a Router component
โ Correct โ wrap in BrowserRouter:
render(<BrowserRouter><PostCard post={post} /></BrowserRouter>); // โ
Mistake 3 โ Not waiting for async UI updates
โ Wrong โ asserting before the async state update completes:
fireEvent.click(loadButton);
expect(screen.getByText('Post Title')).toBeInTheDocument(); // fails โ data not loaded yet
โ Correct โ use findBy* which waits for the element to appear:
fireEvent.click(loadButton);
expect(await screen.findByText('Post Title')).toBeInTheDocument(); // โ waits
Quick Reference
| Task | Code |
|---|---|
| Render component | render(<Component />) |
| Find by role | screen.getByRole('button', { name: /submit/i }) |
| Find by text | screen.getByText(/hello/i) |
| Assert present | expect(el).toBeInTheDocument() |
| Assert absent | expect(screen.queryByText('x')).not.toBeInTheDocument() |
| Wait for element | await screen.findByText('Loaded!') |
| Click a button | await user.click(screen.getByRole('button')) |
| Type in input | await user.type(screen.getByLabelText('Email'), 'a@b.com') |
| Mock a function | const fn = vi.fn(); expect(fn).toHaveBeenCalled() |