React Testing Library Tutorial: Complete Guide to Testing React Components

React Testing Library promotes testing components the way users interact with them. Instead of testing implementation details, you test behavior, making tests more maintainable and reliable.

Effective testing catches bugs early, improves code quality, and provides confidence for refactoring. Testing Library’s philosophy focuses on testing what matters: the user experience.

Testing Philosophy:
The more your tests resemble how users interact with your app, the more confidence they can give you.

Setup and Installation

Installation

// Using Vite + React
npm install –save-dev @testing-library/react @testing-library/jest-dom vitest// Using Create React App (pre-installed)
npm test

Configure Vitest (vitest.config.ts)

import { defineConfig } from ‘vitest/config’;
import react from ‘@vitejs/plugin-react’;export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: ‘jsdom’,
setupFiles: [‘./src/test/setup.ts’]
}
});

Setup File (src/test/setup.ts)

import ‘@testing-library/jest-dom’;
import { expect, afterEach } from ‘vitest’;
import { cleanup } from ‘@testing-library/react’;// Cleanup after each test
afterEach(() => cleanup());

Finding Elements with Queries

Query Types

Query Type When to Use Returns
getBy* Element should exist Element or throws error
queryBy* Element might not exist Element or null
findBy* Element appears async Promise resolves to element
getAllBy* Multiple elements Array of elements or throws

Using getByRole

The most user-centric query – finds elements by accessibility role:

import { render, screen } from ‘@testing-library/react’;
import { Button } from ‘./Button’;describe(‘Button’, () => {
it(‘renders a button’, () => {
render(<Button>Click me</Button>);

const button = screen.getByRole(‘button’, {
name: /click me/i
});

expect(button).toBeInTheDocument();
});
});

Other Query Methods

// By Label Text (form inputs)
const input = screen.getByLabelText(/username/i);// By Placeholder Text
const searchInput = screen.getByPlaceholderText(/search/i);

// By Text
const heading = screen.getByText(/welcome/i);

// By Test ID (last resort)
const element = screen.getByTestId(‘unique-id’);

✅ Query Priority: Use this order: getByRole → getByLabelText → getByPlaceholderText → getByText → getByTestId

Testing User Interactions

Using userEvent

Simulate realistic user interactions:

import { render, screen } from ‘@testing-library/react’;
import userEvent from ‘@testing-library/user-event’;
import { LoginForm } from ‘./LoginForm’;it(‘submits form with valid data’, async () => {
const user = userEvent.setup();
render(<LoginForm />);

// Type in inputs
const emailInput = screen.getByLabelText(/email/i);
await user.type(emailInput, ‘user@example.com’);

// Click button
const submitButton = screen.getByRole(‘button’, {
name: /submit/i
});
await user.click(submitButton);

// Assert result
expect(screen.getByText(/success/i)).toBeInTheDocument();
});

Common User Interactions

const user = userEvent.setup();// Click
await user.click(element);

// Type text
await user.type(input, ‘text’);

// Clear input
await user.clear(input);

// Select option
await user.selectOptions(select, ‘option1’);

// Check checkbox
await user.click(checkbox);

// Keyboard events
await user.keyboard(‘{Enter}’);

Handling Async Operations

Testing Async Data Loading

import { render, screen, waitFor } from ‘@testing-library/react’;
import { UserList } from ‘./UserList’;it(‘displays users after loading’, async () => {
render(<UserList />);

// Initially shows loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument();

// Wait for data to appear
const userName = await screen.findByText(/john doe/i);
expect(userName).toBeInTheDocument();
});

Using waitFor

import { waitFor } from ‘@testing-library/react’;it(‘updates after async action’, async () => {
render(<Counter />);

const button = screen.getByRole(‘button’);
await userEvent.click(button);

// Wait for condition to be true
await waitFor(() => {
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
});

Mocking API Calls

import { vi } from ‘vitest’;beforeEach(() => {
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({
users: [{ id: 1, name: ‘John’ }]
})
})
);
});

afterEach(() => {
vi.clearAllMocks();
});

Testing Custom Hooks

Using renderHook

import { renderHook, waitFor } from ‘@testing-library/react’;
import { useCounter } from ‘./useCounter’;it(‘increments counter’, async () => {
const { result } = renderHook(() => useCounter());

expect(result.current.count).toBe(0);

await waitFor(() => {
result.current.increment();
expect(result.current.count).toBe(1);
});
});

Testing Hooks with Context

import { renderHook } from ‘@testing-library/react’;it(‘accesses context value’, () => {
const wrapper = ({ children }) => (
<ThemeProvider>{children}</ThemeProvider>
);

const { result } = renderHook(
() => useTheme(),
{ wrapper }
);

expect(result.current.theme).toBe(‘light’);
});

Testing Async Hooks

import { renderHook, waitFor } from ‘@testing-library/react’;
import { useFetchUser } from ‘./useFetchUser’;it(‘fetches user data’, async () => {
const { result } = renderHook(() => useFetchUser(1));

// Initially loading
expect(result.current.loading).toBe(true);

// Wait for data
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.user?.name).toBe(‘John’);
});
});

Mocking and Spying

Mocking Functions

import { vi } from ‘vitest’;it(‘calls handler on click’, async () => {
const handleClick = vi.fn();
const user = userEvent.setup();

render(<Button onClick={handleClick}>Click</Button>);

await user.click(screen.getByRole(‘button’));

// Assert function was called
expect(handleClick).toHaveBeenCalled();
expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleClick).toHaveBeenCalledWith(expectedArg);
});

Mocking Modules

import { vi } from ‘vitest’;vi.mock(‘../api’, () => ({
fetchUser: vi.fn(() =>
Promise.resolve({ id: 1, name: ‘John’ })
)
}));

it(‘uses mocked API’, async () => {
render(<UserProfile />);

await screen.findByText(/john/i);
expect(fetchUser).toHaveBeenCalled();
});

Spying on Methods

const consoleSpy = vi.spyOn(console, ‘log’);it(‘logs message’, () => {
render(<Component />);

expect(consoleSpy).toHaveBeenCalledWith(‘test’);
});

afterEach(() => {
consoleSpy.mockRestore();
});

Testing Best Practices

1. Test User Behavior, Not Implementation

// ❌ Don’t: Test implementation details
it(‘sets state’, () => {
render(<Counter />);
const instance = new Counter();
instance.state.count = 1;
});// ✅ Do: Test user interactions
it(‘displays updated count after click’, async () => {
const user = userEvent.setup();
render(<Counter />);

await user.click(screen.getByRole(‘button’));
expect(screen.getByText(/1/)).toBeInTheDocument();
});

2. Use Semantic Queries

// Priority order for queries:
// 1. getByRole – most semantic
screen.getByRole(‘button’, { name: /submit/i });// 2. getByLabelText – form inputs
screen.getByLabelText(/username/i);

// 3. getByPlaceholderText
screen.getByPlaceholderText(/search/i);

// 4. getByText – headings, labels
screen.getByText(/welcome/i);

// 5. getByTestId – last resort
screen.getByTestId(‘unique-id’);

3. Avoid Testing Library Details

// ❌ Don’t: Reach into component internals
it(‘renders correctly’, () => {
const { container } = render(<Component />);
expect(container.querySelector(‘.internal-class’)).toBeInTheDocument();
});// ✅ Do: Test from user perspective
it(‘displays welcome message’, () => {
render(<Component />);
expect(screen.getByText(/welcome/i)).toBeInTheDocument();
});

4. Keep Tests Isolated

describe(‘LoginForm’, () => {
beforeEach(() => {
// Setup before each test
vi.clearAllMocks();
});afterEach(() => {
// Cleanup after each test
cleanup();
});

it(‘test 1’, () => {});
it(‘test 2’, () => {});
});

✅ Testing Confidence: Good tests are maintainable, readable, and test behavior rather than implementation. They should continue to pass even when refactoring internal code.

Common Testing Patterns

Testing Form Submission

it(‘submits form successfully’, async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();render(<Form onSubmit={handleSubmit} />);

// Fill form
await user.type(
screen.getByLabelText(/name/i),
‘John’
);

// Submit
await user.click(screen.getByRole(‘button’, { name: /submit/i }));

expect(handleSubmit).toHaveBeenCalledWith({ name: ‘John’ });
});

Testing Conditional Rendering

it(‘shows error message when invalid’, () => {
render(<Component data={null} />);expect(screen.getByText(/error/i)).toBeInTheDocument();
});

it(‘shows data when valid’, () => {
render(<Component data={{ id: 1 }} />);

expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
});

Testing Modal/Dialog

it(‘closes modal on button click’, async () => {
const user = userEvent.setup();
const handleClose = vi.fn();render(<Modal isOpen={true} onClose={handleClose} />);

await user.click(screen.getByRole(‘button’, { name: /close/i }));

expect(handleClose).toHaveBeenCalled();
});

© 2025 JavaScript UX. React Testing Library Tutorial.

Leave a Reply

Your email address will not be published. Required fields are marked *