React Testing Library — The Right Way to Test Components
Day 32 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋

Day 32. Yesterday we covered the mindset. Today: the mechanics.
React Testing Library (RTL) is built around one core idea from yesterday: test what the user sees, not how the component is implemented.
The API enforces this. There are no methods to read internal state. No way to call a component method. Everything goes through the DOM — exactly as a user would interact with it.
Let me show you what that looks like in practice.
The query hierarchy — how to find elements
RTL gives you several ways to find elements. Some are better than others:
Priority order (use higher = better):
| Query | When to use |
|---|---|
getByRole |
Almost always — matches semantic HTML |
getByLabelText |
Form inputs with labels |
getByPlaceholderText |
Inputs without visible labels (last resort) |
getByText |
Non-interactive elements with text content |
getByDisplayValue |
Current value of input/select |
getByAltText |
Images |
getByTitle |
Elements with a title attribute |
getByTestId |
Last resort — add data-testid only when nothing else works |
// ❌ Avoid — brittle, implementation-dependent
const button = container.querySelector('.submit-button');
const input = wrapper.find('input[name="email"]');
// ❌ Avoid — data-testid as first resort
screen.getByTestId('submit-button');
// ✅ Use role — how assistive tech identifies it
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { name: /welcome/i });
// ✅ Use label — for form inputs
screen.getByLabelText(/email address/i);
screen.getByLabelText(/password/i);
getByRole is the gold standard. It finds elements by their ARIA role — the same way screen readers navigate. Using it in tests forces you to write accessible HTML. That's a beautiful side effect.
get vs query vs find
Each query comes in three flavours:
| Prefix | Returns | Throws if missing | Use when |
|---|---|---|---|
get |
Element | ✅ Yes | Element should be in DOM right now |
query |
Element or null |
❌ No | Asserting element is NOT in DOM |
find |
Promise<Element> | ✅ Yes (after timeout) | Element appears asynchronously |
// ✅ getBy — element is in DOM synchronously
const button = screen.getByRole('button', { name: /submit/i });
// ✅ queryBy — checking element is absent
expect(screen.queryByText('Error message')).not.toBeInTheDocument();
// ✅ findBy — element appears after async action
const successMessage = await screen.findByText('Profile saved!');
The most common mistake: using getBy for async content (throws immediately before it renders) or findBy for synchronous content (unnecessarily async).
A real component test — the full workflow
Let's test a login form:
// components/LoginForm.tsx
type LoginFormProps = {
onSuccess: (user: User) => void;
};
export function LoginForm({ onSuccess }: LoginFormProps) {
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const email = (form.elements.namedItem('email') as HTMLInputElement).value;
const password = (form.elements.namedItem('password') as HTMLInputElement).value;
setIsLoading(true);
setError(null);
try {
const user = await authService.login({ email, password });
onSuccess(user);
} catch {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email address</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
Now the tests:
// components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
import { authService } from '@/services/authService';
// Mock the auth service
vi.mock('@/services/authService');
const mockUser = { id: '1', name: 'Richa', email: 'richa@example.com' };
describe('LoginForm', () => {
// Test 1 — happy path
it('calls onSuccess with user after successful login', async () => {
const onSuccess = vi.fn();
vi.mocked(authService.login).mockResolvedValue(mockUser);
render(<LoginForm onSuccess={onSuccess} />);
await userEvent.type(screen.getByLabelText(/email address/i), 'richa@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSuccess).toHaveBeenCalledWith(mockUser);
});
// Test 2 — error state
it('shows error message when login fails', async () => {
vi.mocked(authService.login).mockRejectedValue(new Error('Invalid credentials'));
render(<LoginForm onSuccess={vi.fn()} />);
await userEvent.type(screen.getByLabelText(/email address/i), 'wrong@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'wrongpassword');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password');
});
// Test 3 — loading state
it('disables button and shows loading text while submitting', async () => {
// Never resolves — keeps us in loading state
vi.mocked(authService.login).mockImplementation(() => new Promise(() => {}));
render(<LoginForm onSuccess={vi.fn()} />);
await userEvent.type(screen.getByLabelText(/email address/i), 'richa@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
const button = screen.getByRole('button');
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Signing in...');
});
});
Notice what we're testing:
- What the user sees (error message, loading text)
- What happens as a result of interactions (
onSuccesscalled) - Accessible queries throughout (
getByLabelText,getByRole)
Not tested:
- Internal state (
isLoading,error) - Which service method was called internally (except as a way to set up mock behaviour)
userEvent vs fireEvent
Always prefer userEvent over fireEvent:
// ❌ fireEvent — fires individual DOM events, doesn't simulate real interaction
fireEvent.change(input, { target: { value: 'hello' } });
fireEvent.click(button);
// ✅ userEvent — simulates real user behaviour (focus, type each character, blur, etc.)
await userEvent.type(input, 'hello');
await userEvent.click(button);
userEvent.type fires keydown, keypress, keyup, input, and change events for each character — just like a real user. It also handles focus and blur. fireEvent.change just fires a single change event, skipping everything else. For most tests this matters.
Wrapping with providers
Real components need providers (React Query, Router, Auth). Create a custom render helper:
// src/test/test-utils.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '@/context/AuthContext';
function TestProviders({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }, // don't retry in tests
mutations: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<AuthProvider>
{children}
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
// Override render with providers
function customRender(ui: ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: TestProviders, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
// In tests — import from test-utils, not @testing-library/react
import { render, screen } from '@/test/test-utils';
it('renders product list', async () => {
render(<ProductsPage />); // has access to QueryClient, Router, Auth
// ...
});
Common assertions
// In the DOM
expect(element).toBeInTheDocument();
// Not in the DOM
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// Visible (not hidden by CSS or aria-hidden)
expect(element).toBeVisible();
// Disabled
expect(button).toBeDisabled();
expect(button).not.toBeDisabled();
// Text content
expect(element).toHaveTextContent('Welcome, Richa');
// Attribute
expect(input).toHaveAttribute('type', 'email');
expect(input).toHaveValue('richa@example.com');
// ARIA
expect(element).toHaveAccessibleName('Email address');
expect(checkbox).toBeChecked();
Today's takeaway
React Testing Library's API steers you toward good tests by design:
- No internal state access → you can't test implementation details
- Role-based queries → you're forced to write accessible HTML
userEventoverfireEvent→ more realistic interaction simulationfindByfor async → tests wait for async rendering naturally
The pattern: render → interact → assert on what the user sees. That's it. That pattern, consistently applied, gives you a test suite that's genuinely valuable.

See you tomorrow for Day 33 — Testing custom hooks with renderHook.
← Day 31 — Testing Philosophy → Day 33 — Testing Custom Hooks
Part of the React System Design Series — 40 days, one topic per day.





