본문 바로가기

React

React Testing Library로 컴포넌트 단위 테스트 작성하기

React Testing Library(RTL)는 사용자의 관점에서 컴포넌트를 테스트하도록 돕는 도구입니다. DOM 구조나 내부 상태 같은 구현 세부에 의존하지 않고, 접근성 가능한 쿼리로 화면을 찾고 사용자 이벤트를 시뮬레이션합니다. 실무에서 바로 쓸 수 있는 설정과 패턴을 예제로 정리합니다.

1. 설치와 기본 설정

Jest와 RTL, jest-dom, user-event를 설치합니다. 테스트 환경은 jsdom이어야 합니다.

// 설치
// npm
npm i -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
// yarn
yarn add -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
};
// src/setupTests.js
import '@testing-library/jest-dom';

package.json에 스크립트를 추가합니다.

{
  "scripts": {
    "test": "jest --watch"
  }
}

2. 첫 번째 테스트: 버튼 토글

접근성 가능한 역할(role)과 이름(name)으로 요소를 찾고, user-event로 상호작용합니다.

// ToggleButton.jsx
import { useState } from 'react';

export default function ToggleButton() {
  const [on, setOn] = useState(false);
  return (
    <button aria-pressed={on} onClick={() => setOn((v) => !v)}>
      {on ? '켜짐' : '꺼짐'}
    </button>
  );
}
// ToggleButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ToggleButton from './ToggleButton';

test('버튼 토글 시 텍스트와 aria-pressed가 바뀝니다', async () => {
  render(<ToggleButton />);
  const user = userEvent.setup();

  const button = screen.getByRole('button', { name: '꺼짐' });
  expect(button).toHaveAttribute('aria-pressed', 'false');

  await user.click(button);

  expect(button).toHaveTextContent('켜짐');
  expect(button).toHaveAttribute('aria-pressed', 'true');
});

3. 접근성 우선 쿼리 전략

RTL은 접근성 우선 쿼리를 권장합니다. 우선순위는 대략 다음과 같습니다.

1) getByRole 2) getByLabelText 3) getByPlaceholderText 4) getByText 5) getByDisplayValue 6) getByAltText 7) getByTitle 8) getByTestId(최후)

// 폼 라벨-컨트롤 연결
// Form.jsx
export function Form() {
  return (
    <form>
      <label htmlFor="email">이메일</label>
      <input id="email" type="email" placeholder="name@example.com" />
      <button type="submit">전송</button>
    </form>
  );
}
// Form.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Form } from './Form';

it('라벨로 인풋을 찾고 입력할 수 있습니다', async () => {
  render(<Form />);
  const user = userEvent.setup();

  const email = screen.getByLabelText(/이메일/);
  await user.type(email, 'a@b.com');
  expect(email).toHaveValue('a@b.com');

  // 역할과 접근 가능한 이름으로 버튼 찾기
  const submit = screen.getByRole('button', { name: '전송' });
  expect(submit).toBeEnabled();
});

4. 비동기 UI: findBy와 waitFor

비동기 렌더링은 findBy 계열 쿼리(내장 타임아웃 동안 대기)나 waitFor를 사용합니다. 네트워크는 모듈 경계에서 모킹합니다.

// Greeting.jsx
import { useEffect, useState } from 'react';

export function Greeting() {
  const [name, setName] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch('/api/me')
      .then((r) => r.json())
      .then((data) => { if (!ignore) setName(data.name); });
    return () => { ignore = true; };
  }, []);

  return <div>{name ? `안녕하세요, ${name}` : '로딩...' }</div>;
}
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';

it('API 응답 후 인사말을 표시합니다', async () => {
  const mock = jest.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => ({ name: '민수' }),
  });

  render(<Greeting />);

  expect(screen.getByText('로딩...')).toBeInTheDocument();

  // DOM에 나타날 때까지 대기
  expect(await screen.findByText('안녕하세요, 민수')).toBeInTheDocument();

  mock.mockRestore();
});

컴포넌트 외부 네트워크 경계에서 모킹하려면 MSW(Mock Service Worker) 사용을 권장합니다. 테스트가 구현 세부에 덜 결합되고, 통합 흐름을 자연스럽게 검증할 수 있습니다.

5. user-event로 실제에 가까운 상호작용

user-event는 타이핑 지연, 포커스 이동 등 브라우저 동작을 더 현실적으로 시뮬레이션합니다. 비동기 동작을 고려해 setup을 사용하고 await를 붙입니다.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

it('Enter 키 제출과 포커스 이동을 처리합니다', async () => {
  render(<form>
    <input aria-label="이름" />
    <button>저장</button>
  </form>);

  const user = userEvent.setup();
  const input = screen.getByLabelText('이름');
  await user.type(input, '홍길동{enter}');
  // 제출 핸들러가 있다면 제출되었는지 검증, 여기서는 버튼이 포커스를 받았다고 가정
  expect(screen.getByRole('button', { name: '저장' })).toHaveFocus();
});

6. 컨텍스트/라우터 등 Provider가 필요한 컴포넌트

여러 Provider가 필요한 컴포넌트를 매번 감싸는 대신, 커스텀 렌더 함수를 만들어 재사용합니다.

// test-utils.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

function Providers({ children }) {
  return <MemoryRouter>{children}</MemoryRouter>;
}

export function renderWithProviders(ui, options) {
  return render(ui, { wrapper: Providers, ...options });
}

export * from '@testing-library/react';
// Page.test.jsx
import { screen } from '@testing-library/react';
import { renderWithProviders } from './test-utils';
import Page from './Page';

it('페이지 제목을 렌더링합니다', () => {
  renderWithProviders(<Page />);
  expect(screen.getByRole('heading', { level: 1, name: /대시보드/ })).toBeInTheDocument();
});

7. 모듈/타이머 모킹 팁

모듈 의존성을 고립시키려면 jest.mock을 사용합니다. 타이머 기반 로직은 가짜 타이머로 제어할 수 있습니다.

// 모듈 모킹 예시
jest.mock('./analytics', () => ({ track: jest.fn() }));

// 타이머 제어 예시
jest.useFakeTimers();

it('디바운스 후 호출됩니다', () => {
  const fn = jest.fn();
  render(<button onClick={() => setTimeout(fn, 300)}>Run</button>);
  screen.getByRole('button', { name: 'Run' }).click();
  jest.advanceTimersByTime(300);
  expect(fn).toHaveBeenCalled();
});

afterEach(() => {
  jest.useRealTimers();
});

8. 피해야 할 테스트 냄새

- 구현 세부 의존: className, 내부 state 값 직접 확인, container.querySelector로 깊게 파고들기

- 스냅샷 남용: 큰 트리 스냅샷은 노이즈가 큽니다. 대신 화면 텍스트, 역할, 속성으로 구체적 단언을 합니다.

- testId 남용: 접근 가능한 역할/이름으로 찾을 수 있다면 우선 사용합니다. testId는 진짜 대안이 없을 때만 사용합니다.

9. 디버깅 도구

- screen.debug(): 현재 DOM 출력

- logRoles(container): 어떤 role이 할당됐는지 확인

import { render, screen } from '@testing-library/react';
import { logRoles } from '@testing-library/dom';

const { container } = render(<YourComponent />);
logRoles(container);
screen.debug();

10. 체크리스트

- 사용자 관점으로 쓰여졌는가? (getByRole/LabelText 우선)

- 비동기는 findBy/waitFor를 사용했는가?

- Provider는 커스텀 렌더로 감쌌는가?

- 네트워크/시간/모듈 경계를 적절히 모킹했는가?

- 과도한 스냅샷과 testId를 피했는가?

위 원칙을 지키면, 리팩터링에 강하고 유지보수 비용이 낮은 테스트를 작성할 수 있습니다. 테스트는 구현이 아닌 동작을 검증해야 합니다.