After 62 days of building React and Next.js applications, you've shipped features, fixed bugs, and learned what production means. But if your test suite doesn't give you confidence to refactor without fear, you're not testing React the right way. Testing React the right way means writing tests that verify what users actually see and do, not what's happening inside your component's state or implementation.
React Testing Library philosophy — Query by role and label, not by class names or internals
React Testing Library exists because testing implementation details creates brittle tests. The moment you refactor your component's internal structure, your tests break—even though the component works perfectly from a user's perspective. The library enforces a simple principle: query by what users interact with.
Instead of hunting for elements by CSS class, data attributes, or component state, you query by accessible role and label. This approach mirrors how real users interact with your app: they see a button labeled "Sign in" or an input with the label "Email", not className="login-btn-primary".
Here's a concrete example. You have a login form:
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
setError('Invalid credentials');
return;
}
window.location.href = '/dashboard';
} catch (err) {
setError('Network error');
}
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email-input">Email</label>
<input
id="email-input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password-input">Password</label>
<input
id="password-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <div role="alert">{error}</div>}
<button type="submit">Sign in</button>
</form>
);
}Using React Testing Library, you test it like a user would:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('user can log in with valid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// User types into the email input by its label
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'password123');
// User clicks the sign-in button by its role and visible text
await user.click(screen.getByRole('button', { name: 'Sign in' }));
// User sees no error (assuming successful login)
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});Notice what we're not doing: we're not checking internal state, we're not querying by class name, we're not mocking React internals. We're interacting with the form exactly as a human would—find the email input by its label, type into it, find the submit button by its role and label, click it, and verify the outcome.
The key queries you'll use are:
getByRole(role, { name })— Find buttons, headings, textboxes by their accessible role and label.getByLabelText(label)— Find form inputs by their associated label.getByPlaceholderText(text)— Find inputs by placeholder (less ideal, but useful).getByText(text)— Find any element by its visible text content.getByDisplayValue(value)— Find inputs that currently contain a specific value.
Avoid getByTestId unless you genuinely have no semantic way to query the element. Test IDs are implementation details.
Avoid implementation details — No testing state or private functions
Implementation details are things that users don't care about: component state, private functions, the exact HTML structure. When you test implementation details, you couple your tests to your code's internals. Refactor the internals, and your test breaks—even if the user-facing behavior hasn't changed.
Here's what not to do:
// Bad: testing state directly
test('email state updates', () => {
const { rerender } = render(<LoginForm />);
const instance = /* can't actually get this easily anymore */;
expect(instance.state.email).toBe('');
// This pattern is outdated; functional components don't expose state
});
// Bad: testing internal functions
test('validateEmail works', () => {
const { validateEmail } = LoginForm; // Not exported; this won't work
expect(validateEmail('a@x.com')).toBe(true);
});
// Bad: querying by class name or data-testid when you shouldn't
test('shows form', () => {
const { container } = render(<LoginForm />);
expect(container.querySelector('.login-form')).toBeInTheDocument();
});Instead, test behavior from the user's perspective:
// Good: user sees the form and can interact with it
test('form is visible and usable', async () => {
const user = userEvent.setup();
render(<LoginForm />);
const emailInput = screen.getByLabelText('Email');
const passwordInput = screen.getByLabelText('Password');
const submitButton = screen.getByRole('button', { name: 'Sign in' });
expect(emailInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
});
// Good: test the actual behavior—user types and submits
test('shows error on invalid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});Why does this matter? Imagine you refactor LoginForm to use a custom hook for form logic, or you change the internal state structure. As long as the form's DOM output and behavior remain the same, your tests still pass. You've decoupled your tests from implementation, which is the entire point.
Mock the network, not components — MSW returns realistic responses
Every component with a network request needs a strategy to avoid hitting real servers during tests. The worst approach is mocking entire components. The best approach is mocking the HTTP layer with Mock Service Worker (MSW).
MSW intercepts your fetch or axios calls at the network level and returns mock responses. Your component logic runs normally, just with fake data. This is much closer to real-world behavior than replacing the component with a stub.
Set up MSW in your test file or a shared setup:
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
const server = setupServer(
http.post('/api/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'a@x.com' && body.password === 'correct') {
return HttpResponse.json({ token: 'abc123' });
}
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('successful login redirects to dashboard', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'correct');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
// Component makes real fetch call, MSW intercepts and returns success
// Component logic handles the response normally
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('failed login shows error', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});MSW is powerful because you can also test edge cases: network timeouts, malformed responses, slow endpoints. Override the mock handler for specific tests:
test('shows error on network failure', async () => {
server.use(
http.post('/api/login', () => {
return HttpResponse.error();
})
);
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'password');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Network error');
});Fewer, better tests — Cover behavior that matters to users and the business
It's tempting to aim for 100% code coverage, but 100% coverage with bad tests is worse than 70% coverage with focused tests. Write tests that cover the journeys and edge cases users actually encounter, not every if statement and conditional return.
For a login form, the user journeys are:
- User submits valid credentials → redirected to dashboard.
- User submits invalid credentials → sees error.
- User experiences network failure → sees error.
- User submits incomplete form → form doesn't submit (validation).
Those four tests cover the behavior that matters. You don't need a test for "email state updates" or "password input renders" because those are implementation details. The user doesn't care about state; they care about outcomes.
Here's a focused test suite:
describe('LoginForm', () => {
test('submits with valid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'correct');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
test('shows error on invalid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Invalid credentials');
});
test('shows error on network failure', async () => {
server.use(
http.post('/api/login', () => HttpResponse.error())
);
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'password');
await user.click(screen.getByRole('button', { name: 'Sign in' }));
expect(screen.getByRole('alert')).toHaveTextContent('Network error');
});
test('disables submit button while loading', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText('Email'), 'a@x.com');
await user.type(screen.getByLabelText('Password'), 'correct');
const submitButton = screen.getByRole('button', { name: 'Sign in' });
await user.click(submitButton);
expect(submitButton).toBeDisabled();
});
});Four tests, clear outcomes, real user behavior. This is testing React the right way.
Common mistakes
- Over-mocking: Don't mock your own components or custom hooks. Mock external dependencies (API calls, third-party libraries). Your component logic should run real.
- Testing props alone: Passing a prop to a component doesn't prove the component works. Test the outcome—does the user see what they expect?
- Waiting for state: Don't use
waitFor(() => expect(state).toBe(...)). Wait for DOM changes instead:await screen.findByText('Success'). - Not waiting at all: Async operations (typing, clicking, network calls) need
awaitanduserEvent.setup(). Forgetting this causes flaky tests. - Testing the test library: If your test setup is more complex than your component, you're testing the wrong thing. Keep tests simple.
Key takeaways
- Query by role and label, not by class names or test IDs. This forces you to build accessible components and test like a user.
- Avoid implementation details—test behavior, not state or private functions. Your tests should survive refactors.
- Mock the network with MSW, not your components. Let your component logic run against realistic responses.
- Write fewer, focused tests that cover user journeys and business-critical behavior. 70% coverage with good tests beats 100% with flaky ones.
- Use
userEventfor realistic interactions and alwaysawaitasync operations. - If your test is hard to write, it's often a sign your component is doing too much. Simplify the component, and the test becomes obvious.






Comments
Loading comments…