Skip to main content

React Component Testing

Testing React components presents a unique challenge: components are not just functions that return a value — they render UI, respond to user events, manage state, and talk to APIs. The question is always what to test and at what level.

React Testing Library (RTL) answers this with a guiding philosophy: test the way your users interact with the application, not the implementation details. This means querying for elements the way a user finds them (by their visible text, their role, their label) and interacting with them through events, not by reaching into component internals.

Setup

With Jest

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install --save-dev jest-environment-jsdom

Update jest.config.ts:

const config: Config = {
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['<rootDir>/src/setupTests.ts'],
};

Create src/setupTests.ts:

import '@testing-library/jest-dom';

With Vitest

npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Update vite.config.ts:

export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.ts',
},
});

src/setupTests.ts is the same: import '@testing-library/jest-dom';

The Core Concepts

render()

render() mounts a React component into a virtual DOM. It returns a collection of query utilities, but most of the time you use the screen object instead because it scopes queries to the whole document.

import { render, screen } from '@testing-library/react';
import { Button } from './Button';

test('renders the button label', () => {
render(<Button label="Save changes" />);
expect(screen.getByText('Save changes')).toBeInTheDocument();
});

screen — Querying Elements

RTL provides three families of query:

FamilygetByqueryByfindBy
Throws if not foundYesNo (returns null)Yes (async)
Throws if multipleYesYesYes
Use whenElement must be presentAsserting absenceElement appears asynchronously

Each family has a *AllBy* variant that returns an array.

Query Types

QueryFinds element byRTL priority
ByRoleARIA role + accessible nameFirst choice
ByLabelText<label> associated with inputSecond for form fields
ByPlaceholderTextplaceholder attributeAvoid if possible
ByTextVisible text contentGood for non-interactive content
ByDisplayValueCurrent value of input/select/textareaFor controlled inputs
ByAltTextalt attribute on imagesImages
ByTitletitle attributeLast resort
ByTestIddata-testid attributeLast resort

RTL strongly encourages ByRole because it simultaneously tests that your elements have correct semantic HTML and accessible names.

// These all find the same "Submit" button:
screen.getByRole('button', { name: 'Submit' }); // preferred
screen.getByText('Submit'); // ok
screen.getByTestId('submit-btn'); // avoid

Your First Component Test

// src/components/Greeting.tsx
interface Props {
name: string;
}

export function Greeting({ name }: Props) {
return <h1>Hello, {name}!</h1>;
}
// src/components/Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

it('renders a greeting with the provided name', () => {
render(<Greeting name="Alice" />);
expect(screen.getByRole('heading', { name: 'Hello, Alice!' })).toBeInTheDocument();
});

fireEvent vs userEvent

Both simulate user actions, but they differ significantly:

AspectfireEventuserEvent (v14+)
What it doesFires a single DOM eventSimulates real user behaviour (multiple events, focus, keyboard)
TypingfireEvent.change(input, { target: { value: 'x' } })await userEvent.type(input, 'hello world')
ClickingfireEvent.click(button)await userEvent.click(button)
Use whenSimple, synchronous event testsAny realistic user interaction

Use userEvent for almost everything. It is more realistic and catches more bugs.

import userEvent from '@testing-library/user-event';

// At the top of each test, set up userEvent
const user = userEvent.setup();

it('types into an input', async () => {
render(<SearchInput />);
const input = screen.getByRole('textbox', { name: /search/i });
await user.type(input, 'testing library');
expect(input).toHaveValue('testing library');
});

Testing a Form

// src/components/LoginForm.tsx
import { useState } from 'react';

interface Props {
onSubmit: (email: string, password: string) => void;
}

export function LoginForm({ onSubmit }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError('Both fields are required');
return;
}
onSubmit(email, password);
};

return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Log in</button>
</form>
);
}
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();

beforeEach(() => {
mockSubmit.mockClear();
render(<LoginForm onSubmit={mockSubmit} />);
});

it('calls onSubmit with email and password when form is valid', async () => {
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
await user.click(screen.getByRole('button', { name: 'Log in' }));

expect(mockSubmit).toHaveBeenCalledOnce();
expect(mockSubmit).toHaveBeenCalledWith('alice@example.com', 'secret123');
});

it('shows an error when fields are empty', async () => {
await user.click(screen.getByRole('button', { name: 'Log in' }));

expect(screen.getByRole('alert')).toHaveTextContent('Both fields are required');
expect(mockSubmit).not.toHaveBeenCalled();
});

it('shows an error when only email is provided', async () => {
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.click(screen.getByRole('button', { name: 'Log in' }));

expect(screen.getByRole('alert')).toBeInTheDocument();
});
});

Testing Async Data Fetching

Components that fetch data on mount are common. Test them by mocking the data layer and using findBy* queries (which wait for the element to appear).

// src/components/UserProfile.tsx
import { useEffect, useState } from 'react';

interface User { id: number; name: string; email: string; }

export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');

useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('User not found');
return res.json();
})
.then(setUser)
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [userId]);

if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">Error: {error}</p>;
if (!user) return null;

return (
<section>
<h1>{user.name}</h1>
<p>{user.email}</p>
</section>
);
}
// src/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

function mockFetch(data: unknown, ok = true) {
global.fetch = jest.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(data),
} as unknown as Response);
}

afterEach(() => jest.restoreAllMocks());

it('shows the user name and email after loading', async () => {
mockFetch({ id: 1, name: 'Alice', email: 'alice@example.com' });

render(<UserProfile userId={1} />);

expect(screen.getByText('Loading...')).toBeInTheDocument();

// findBy waits for the element to appear (up to 1s by default)
expect(await screen.findByRole('heading', { name: 'Alice' })).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});

it('shows an error message when the request fails', async () => {
mockFetch({ error: 'not found' }, false);

render(<UserProfile userId={99} />);

const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('User not found');
});

Testing Context and Providers

When a component consumes a React context, wrap it in the provider for testing:

import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { ThemedButton } from './ThemedButton';

function renderWithTheme(ui: React.ReactElement, theme = 'light') {
return render(
<ThemeProvider value={theme}>{ui}</ThemeProvider>
);
}

it('applies dark class when theme is dark', () => {
renderWithTheme(<ThemedButton>Click me</ThemedButton>, 'dark');
expect(screen.getByRole('button')).toHaveClass('btn--dark');
});

Create a custom renderWithProviders helper that wraps in all your app's providers (router, theme, auth, store). Put it in a shared test utility file.

Useful Jest-DOM Matchers

@testing-library/jest-dom extends Jest's expect with DOM-specific matchers:

expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toBeEnabled();
expect(element).toHaveTextContent('hello');
expect(element).toHaveValue('alice@example.com');
expect(element).toHaveClass('active');
expect(element).toHaveAttribute('href', '/about');
expect(element).toHaveFocus();
expect(element).toBeChecked();

What Not to Test

RTL's philosophy helps avoid bad tests, but it is worth being explicit:

  • Do not test implementation details: component state values, method calls on the component instance, internal event handler references
  • Do not test the library itself: do not test that React renders your JSX — test that the rendered output is what a user would see
  • Do not snapshot everything: see chapter 9 for when snapshots are appropriate
  • Do not duplicate coverage: if LoginForm is already tested thoroughly, the page that renders it does not need to re-test the form logic

Focus your component tests on behaviour: what happens when a user does something, and what does the component display in each state (loading, success, empty, error).