React Testing Library Complete Guide
이 글의 핵심
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'), '[email protected]');
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'), '[email protected]');
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: '[email protected]',
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](/en/blog/msw-complete-guide/
- Test with [Vitest](/en/blog/vitest-complete-guide/
- E2E with [Playwright](/en/blog/playwright-complete-guide/
Resources:
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Complete React Testing Library guide for React components. Learn queries, user events, async testing, accessibility, and… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Jest Complete Guide | JavaScript Testing· Mocking](/en/blog/jest-complete-guide/
- [Cypress E2E Testing | Selectors· cy.intercept](/en/blog/cypress-e2e-testing-guide/
- [Jest Testing Guide | Unit Tests· Mocks](/en/blog/jest-testing-guide/
이 글에서 다루는 키워드 (관련 검색어)
Testing, React, React Testing Library, Jest, Vitest, JavaScript 등으로 검색하시면 이 글이 도움이 됩니다.