Testing React Apps — React Testing Library & Vitest (2026)
In this tutorial, you'll learn about Testing React Apps. We cover key concepts, practical examples, and best practices.
Testing React applications involves verifying that components render correctly, respond to user interactions, handle asynchronous operations, and maintain accessibility — using tools like React Testing Library and Vitest.
What You'll Learn
You'll understand the Testing Library philosophy (test behavior, not implementation), set up Vitest with React Testing Library, test hooks and async behavior, mock API calls, and test for accessibility.
Why Testing React Apps Matters
React components are the building blocks of modern web UIs. A broken component can break the entire user experience. At DodaTech, Doda Browser's settings panels and tab management UI are tested with React Testing Library to ensure every button, form, and navigation element works correctly.
React Testing Learning Path
flowchart LR A[Jest] --> B[React Testing Library] B --> C[Testing Hooks] B --> D[Mocking API Calls] B --> E[Accessibility Testing] style B fill:#f90,color:#fff
Testing Library Philosophy
The guiding principle: test how users interact with your application, not implementation details.
| Test This | Not This |
|---|---|
| Button text visible | Component state value |
| Form submission behavior | Internal handler name |
| Accessible labels | CSS class names |
| Rendered output | Lifecycle method calls |
Setting Up Vitest + React Testing Library
npm install --save-dev vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.js',
},
});
// src/test/setup.js
import '@testing-library/jest-dom';
Testing Basic Components
// Greeting.jsx
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('renders greeting with name', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});
Expected output:
PASS ./Greeting.test.jsx
✓ renders greeting with name (12 ms)
Testing User Interactions
Use @testing-library/user-event for realistic user interactions.
// Counter.jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('increments count when button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
test('resets count to zero', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Increment' }));
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
Expected output:
PASS ./Counter.test.jsx
✓ increments count when button is clicked (25 ms)
✓ resets count to zero (18 ms)
Testing Hooks
Custom hooks can be tested with renderHook.
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => { result.current.increment(); });
expect(result.current.count).toBe(1);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => { result.current.increment(); result.current.reset(); });
expect(result.current.count).toBe(10);
});
Expected output:
PASS ./useCounter.test.js
✓ initializes with default value (3 ms)
✓ increments count (2 ms)
✓ resets to initial value (2 ms)
Testing Async Behavior
// UserProfile.jsx
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { setUser(data); setLoading(false); });
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div><h2>{user.name}</h2><p>{user.email}</p></div>;
}
// UserProfile.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';
beforeEach(() => {
global.fetch = jest.fn();
});
test('shows loading state initially', () => {
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('renders user data after fetch', async () => {
global.fetch.mockResolvedValue({
json: async () => ({ id: 1, name: 'Alice', email: 'alice@test.com' }),
});
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
Expected output:
PASS ./UserProfile.test.jsx
✓ shows loading state initially (5 ms)
✓ renders user data after fetch (15 ms)
Testing Accessibility
Testing Library includes built-in accessibility queries.
// NavMenu.jsx
function NavMenu() {
return (
<nav aria-label="Main navigation">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</nav>
);
}
// NavMenu.test.jsx
import { render, screen } from '@testing-library/react';
import NavMenu from './NavMenu';
test('finds navigation by role', () => {
render(<NavMenu />);
const nav = screen.getByRole('navigation', { name: 'Main navigation' });
expect(nav).toBeInTheDocument();
});
test('finds links by accessible name', () => {
render(<NavMenu />);
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/home');
});
Expected output:
PASS ./NavMenu.test.jsx
✓ finds navigation by role (7 ms)
✓ finds links by accessible name (3 ms)
Best Practices
1. Find Elements Like Users Do
Use getByRole, getByLabelText, getByText — not getByTestId unless necessary.
2. Avoid Testing Implementation Details
Don't assert on component state, props, or internal methods. Test rendered output.
3. Use userEvent Over fireEvent
userEvent simulates real browser interactions (hover, focus, keyboard) — more realistic.
4. Keep Tests Isolated
Each test should render the component fresh. No shared state.
5. Mock External Boundaries Only
Mock API calls and browser APIs, not internal modules.
Common Mistakes
1. Over-Using data-testid
Adding test IDs everywhere couples tests to implementation. Prefer semantic queries.
2. Testing Implementation Not Behavior
Asserting on component state instead of visible output makes tests brittle.
3. Not Using waitFor for Async
Testing async updates without waitFor leads to flaky tests.
4. Too Many Snapshot Tests
Large snapshots obscure real changes. Use targeted assertions.
5. Not Cleaning Up Mocks
Mocked global functions leak between tests. Use beforeEach to clear.
6. Testing Every Component the Same Way
Presentation components need different testing than container components.
7. Ignoring Accessibility in Tests
If you can't find an element by role, neither can screen readers.
Practice Questions
1. What is the Testing Library philosophy? Test how users interact with your application, not implementation details. Use semantic queries over test IDs.
2. What is userEvent and why is it preferred over fireEvent?
userEvent simulates real browser interactions — click, type, hover — with realistic event sequences.
3. How do you test a custom React hook?
Use renderHook from Testing Library and act to wrap state updates.
4. How do you mock a global fetch call?
Assign global.fetch = jest.fn() in beforeEach and use mockResolvedValue.
5. Challenge: Build and test a search input with debounce. Create a component that fetches results after the user stops typing for 300ms. Test the loading, results, and empty states.
Mini Project: Todo App Tests
// TodoApp.jsx
import { useState } from 'react';
function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
if (input.trim()) {
setTodos([...todos, { id: Date.now(), text: input, done: false }]);
setInput('');
}
};
const toggleTodo = (id) => {
setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t));
};
return (
<div>
<h1>Todo App</h1>
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Add a todo"
aria-label="New todo"
/>
<button onClick={addTodo}>Add</button>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
</label>
</li>
))}
</ul>
{todos.length === 0 && <p data-testid="empty">No todos yet</p>}
</div>
);
}
FAQ
What's Next
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro