React Testing Library Complete Guide | User-Centric Component Testing

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

  1. getByRole (preferred)
  2. getByLabelText (forms)
  3. getByPlaceholderText (forms)
  4. getByText (non-interactive)
  5. getByDisplayValue (forms)
  6. getByAltText (images)
  7. getByTitle (tooltips)
  8. 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:

  1. Query by role for accessibility
  2. Use user-event for interactions
  3. Test behavior, not implementation
  4. findBy for async operations
  5. Custom render for providers

Next Steps:

Resources: