React Testing Library Complete Guide | User-Centric Component Testing
이 글의 핵심
React Testing Library encourages testing components the way users interact with them. It provides utilities to query elements by accessibility attributes and simulate user behavior.
Introduction
React Testing Library (RTL) is a testing utility that encourages good testing practices. It focuses on testing components from the user’s perspective rather than implementation details.
Bad Practice (Enzyme-style)
// Testing implementation details
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
wrapper.instance().increment();
expect(wrapper.state('count')).toBe(1);
Problems:
- ❌ Tests internal state
- ❌ Breaks when refactoring
- ❌ Not how users interact
Good Practice (RTL)
// Testing user behavior
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
Benefits:
- ✅ Tests what users see
- ✅ Refactor-safe
- ✅ Catches real bugs
1. Installation
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
2. Basic Test
// Counter.tsx
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import { Counter } from './Counter';
test('increments count on button click', () => {
render(<Counter />);
// Initial state
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Click button
const button = screen.getByRole('button', { name: /increment/i });
button.click();
// Updated state
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
3. Queries
Priority Order
- getByRole (preferred)
- getByLabelText (forms)
- getByPlaceholderText (forms)
- getByText (non-interactive)
- getByDisplayValue (forms)
- getByAltText (images)
- getByTitle (tooltips)
- getByTestId (last resort)
getByRole
// Most accessible query
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { name: /welcome/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('checkbox', { name: /accept terms/i });
getByLabelText
// For form inputs
render(
<label>
Email
<input type="email" />
</label>
);
screen.getByLabelText('Email');
getByText
// For text content
screen.getByText('Hello, World!');
screen.getByText(/hello/i); // Case-insensitive regex
screen.getByText((content, element) => {
return element?.tagName === 'P' && content.startsWith('Hello');
});
Query Variants
// getBy - throws error if not found
screen.getByRole('button');
// queryBy - returns null if not found
screen.queryByRole('button'); // null or element
// findBy - async, waits for element
await screen.findByRole('button'); // Promise<element>
// getAllBy, queryAllBy, findAllBy - multiple elements
screen.getAllByRole('listitem');
4. User Events
npm install --save-dev @testing-library/user-event
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('submits form', async () => {
const user = userEvent.setup();
render(<LoginForm />);
// Type into inputs
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
// Click button
await user.click(screen.getByRole('button', { name: /login/i }));
// Assert
expect(await screen.findByText('Welcome!')).toBeInTheDocument();
});
User Event Methods
const user = userEvent.setup();
// Typing
await user.type(input, 'Hello');
await user.clear(input);
// Clicking
await user.click(button);
await user.dblClick(button);
// Selecting
await user.selectOptions(select, 'option1');
await user.selectOptions(select, ['option1', 'option2']);
// Uploading files
await user.upload(fileInput, file);
// Keyboard
await user.keyboard('{Shift>}A{/Shift}'); // Types "A"
await user.keyboard('{Enter}');
5. Async Testing
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
import { render, screen, waitFor } from '@testing-library/react';
test('loads and displays user', async () => {
render(<UserProfile userId={1} />);
// Initially loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for user to load
expect(await screen.findByText('Alice')).toBeInTheDocument();
// Alternative with waitFor
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
});
6. Testing Forms
function SignupForm({ onSubmit }: { onSubmit: (data: any) => void }) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSubmit(Object.fromEntries(formData));
};
return (
<form onSubmit={handleSubmit}>
<label>
Email
<input name="email" type="email" required />
</label>
<label>
Password
<input name="password" type="password" required />
</label>
<label>
<input name="terms" type="checkbox" required />
Accept terms
</label>
<button type="submit">Sign Up</button>
</form>
);
}
test('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<SignupForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'user@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('checkbox', { name: /accept terms/i }));
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
terms: 'on',
});
});
7. Testing with Context
function UserGreeting() {
const { user } = useAuth();
return <div>Hello, {user.name}!</div>;
}
test('displays user name from context', () => {
const mockUser = { name: 'Alice' };
render(
<AuthContext.Provider value={{ user: mockUser }}>
<UserGreeting />
</AuthContext.Provider>
);
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument();
});
Custom Render Helper
// test-utils.tsx
import { render } from '@testing-library/react';
import { AuthProvider } from './AuthProvider';
export function renderWithAuth(ui: React.ReactElement, options = {}) {
return render(ui, {
wrapper: ({ children }) => (
<AuthProvider>{children}</AuthProvider>
),
...options,
});
}
// Usage
import { renderWithAuth } from './test-utils';
test('test', () => {
renderWithAuth(<UserGreeting />);
});
8. Testing Hooks
import { renderHook, waitFor } from '@testing-library/react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
return { count, increment };
}
test('increments counter', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
9. Accessibility Testing
import { render, screen } from '@testing-library/react';
test('button is accessible', () => {
render(<button>Click me</button>);
// Can be found by role
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
test('image has alt text', () => {
render(<img src="logo.png" alt="Company logo" />);
expect(screen.getByAltText('Company logo')).toBeInTheDocument();
});
test('form is accessible', () => {
render(
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
</form>
);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
10. Best Practices
1. Query by Accessibility
// Good: accessible
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText('Email');
// Bad: implementation details
screen.getByTestId('submit-button');
document.querySelector('.btn-submit');
2. Test User Behavior
// Good: user behavior
await user.click(screen.getByRole('button'));
expect(screen.getByText('Success')).toBeInTheDocument();
// Bad: implementation
expect(component.state.submitted).toBe(true);
3. Use findBy for Async
// Good: waits automatically
expect(await screen.findByText('Loaded')).toBeInTheDocument();
// Bad: manual waiting
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
Summary
React Testing Library promotes better tests:
- User-centric - test what users see
- Accessible - encourages a11y best practices
- Maintainable - refactor-safe tests
- Async support - built-in waiting
- Works with Jest, Vitest, any test runner
Key Takeaways:
- Query by role for accessibility
- Use user-event for interactions
- Test behavior, not implementation
- findBy for async operations
- Custom render for providers
Next Steps:
- Mock APIs with MSW
- Test with Vitest
- E2E with Playwright
Resources: