본문 바로가기
카테고리 없음

React Testing Library — 사용자 관점에서 테스트하기

by SuldenLion 2026. 6. 26.
반응형

React Testing Library — 사용자 관점에서 테스트하기

프론트엔드 개발 · 테스팅 시리즈 #3
"구현 세부사항이 아닌 동작을 테스트하라." RTL의 핵심 철학부터 쿼리 우선순위, 사용자 이벤트, 비동기 처리, 커스텀 훅 테스트까지 — 실전에서 바로 쓸 수 있는 완전한 가이드입니다.


목차

  1. React Testing Library란 무엇인가
  2. 설치 및 기본 설정
  3. 쿼리 우선순위 — 올바른 요소 선택법
  4. user-event — 실제 사용자 상호작용
  5. 비동기 테스트 — waitFor, findBy
  6. 컴포넌트 테스트 실전 예제
  7. 커스텀 훅 테스트
  8. Context와 Provider 테스트
  9. MSW로 API 모킹
  10. 결론 — 좋은 RTL 테스트의 원칙

1. React Testing Library란 무엇인가

React Testing Library(RTL) 는 Kent C. Dodds가 만든 React 컴포넌트 테스트 유틸리티입니다. 핵심 철학은 단 하나입니다.

"사용자가 소프트웨어를 사용하는 방식과 유사한 방식으로 테스트할수록, 테스트에 대한 신뢰가 높아진다."

RTL이 해결하는 문제

Enzyme(이전 세대 테스팅 라이브러리)은 컴포넌트의 내부 구현을 테스트했습니다.

// Enzyme 방식 — 구현 세부사항 테스트 (나쁜 패턴)
const wrapper = shallow(<Button />);
expect(wrapper.state('isClicked')).toBe(false);
wrapper.find('.button').simulate('click');
expect(wrapper.state('isClicked')).toBe(true);

// 문제: 상태 이름을 'clicked'로 바꾸면 테스트가 깨짐
// 하지만 사용자 관점에서는 아무것도 바뀌지 않았음

RTL은 실제 DOM을 렌더링하고, 사용자가 실제로 보고 상호작용하는 방식으로 테스트합니다.

// RTL 방식 — 동작 테스트 (좋은 패턴)
render(<Button />);
const button = screen.getByRole('button', { name: '제출' });
await userEvent.click(button);
expect(screen.getByText('제출 완료!')).toBeInTheDocument();

// 내부 상태가 어떻게 바뀌든 관심 없음
// 사용자가 경험하는 결과를 테스트

2. 설치 및 기본 설정

# RTL + jest-dom 설치
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

# Vitest 사용 시 (jsdom 필요)
npm install --save-dev jsdom

setupTests.ts

// src/setupTests.ts
import '@testing-library/jest-dom';
// 이것으로 toBeInTheDocument, toHaveTextContent 등의 매처 사용 가능
// vitest.config.ts
export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles:  ['./src/setupTests.ts'],
    globals:     true,
  },
});

기본 테스트 구조

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

describe('Button 컴포넌트', () => {
  it('텍스트를 렌더링한다', () => {
    render(<Button>클릭하세요</Button>);
    expect(screen.getByText('클릭하세요')).toBeInTheDocument();
  });

  it('클릭 시 onClick을 호출한다', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>클릭</Button>);

    await userEvent.click(screen.getByRole('button'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });
});

3. 쿼리 우선순위 — 올바른 요소 선택법

RTL은 다양한 쿼리를 제공합니다. 접근성을 가장 잘 반영하는 쿼리를 우선 사용해야 합니다.

우선순위 1 — 모든 사람이 접근 가능한 쿼리

// getByRole — 가장 권장 (ARIA 역할 기반)
screen.getByRole('button')
screen.getByRole('button', { name: '제출' })  // accessible name 포함
screen.getByRole('textbox', { name: '이메일' })
screen.getByRole('checkbox', { name: '동의' })
screen.getByRole('heading', { level: 1 })
screen.getByRole('img', { name: '프로필 사진' })
screen.getByRole('link', { name: '홈으로' })
screen.getByRole('combobox')  // select
screen.getByRole('listitem')
screen.getByRole('dialog')

// getByLabelText — 폼 요소 (label과 연결)
screen.getByLabelText('이메일 주소')
screen.getByLabelText(/이메일/)

// getByPlaceholderText — placeholder 텍스트
screen.getByPlaceholderText('hong@example.com')

// getByText — 텍스트 내용
screen.getByText('안녕하세요')
screen.getByText(/안녕/)  // 정규식

// getByDisplayValue — input/select의 현재 값
screen.getByDisplayValue('홍길동')

우선순위 2 — 시맨틱 쿼리

// getByAltText — img의 alt 속성
screen.getByAltText('로고 이미지')

// getByTitle — title 속성
screen.getByTitle('닫기')

우선순위 3 (최후 수단) — 테스트 ID

// getByTestId — data-testid 속성
screen.getByTestId('submit-button')
// HTML: <button data-testid="submit-button">제출</button>
// 접근성 정보가 없을 때만 사용

getBy vs queryBy vs findBy

// getBy — 요소가 없으면 즉시 에러 (동기)
screen.getByRole('button')     // 없으면 에러

// queryBy — 요소가 없으면 null 반환 (동기, 존재 여부 확인에 사용)
screen.queryByRole('button')   // 없으면 null
expect(screen.queryByText('에러')).not.toBeInTheDocument()

// findBy — 비동기, 요소가 나타날 때까지 대기 (Promise)
await screen.findByRole('button')   // 나타날 때까지 기다림
await screen.findByText('로딩 완료')

// AllBy 변형 — 여러 요소 반환
screen.getAllByRole('listitem')
screen.queryAllByRole('button')
await screen.findAllByRole('img')

4. user-event — 실제 사용자 상호작용

@testing-library/user-event v14는 실제 브라우저의 사용자 동작을 시뮬레이션합니다.

import userEvent from '@testing-library/user-event';

// setup() 패턴 권장 (v14+)
const user = userEvent.setup();

// 클릭
await user.click(element);
await user.dblClick(element);        // 더블 클릭
await user.tripleClick(element);     // 세 번 클릭 (텍스트 전체 선택)

// 키보드
await user.type(input, '안녕하세요');   // 한 글자씩 타이핑
await user.clear(input);               // 입력값 지우기
await user.keyboard('{Enter}');        // 특수 키
await user.keyboard('{Tab}');          // Tab 이동
await user.keyboard('{Escape}');       // Esc
await user.keyboard('{Control>}a{/Control}');  // Ctrl+A

// 폼 요소
await user.selectOptions(selectEl, '옵션2');
await user.deselectOptions(multiSelectEl, '옵션1');
await user.upload(fileInput, file);    // 파일 업로드

// 호버
await user.hover(element);
await user.unhover(element);

// 탭 순서
await user.tab();        // 다음 요소로 포커스 이동
await user.tab({ shift: true });  // 이전 요소로

fireEvent vs userEvent

// fireEvent — 단순 DOM 이벤트 발생 (낮은 수준)
fireEvent.click(button);

// userEvent — 실제 브라우저 동작 시뮬레이션 (권장)
// 예: type은 keydown → keypress → input → keyup 이벤트를 순서대로 발생
await user.type(input, 'hello');

// fireEvent.change로는 onChange만 발생하지만
// userEvent.type은 실제 타이핑의 모든 이벤트를 순서대로 발생시킴

5. 비동기 테스트 — waitFor, findBy

// waitFor — 조건이 충족될 때까지 반복 확인
import { waitFor } from '@testing-library/react';

it('데이터 로드 후 목록을 표시한다', async () => {
  render(<UserList />);

  // 로딩 스피너가 사라질 때까지 대기
  await waitFor(() => {
    expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();
  });

  // 또는 findBy로 요소가 나타날 때까지 대기
  const listItems = await screen.findAllByRole('listitem');
  expect(listItems).toHaveLength(3);
});

// waitForElementToBeRemoved — 요소가 사라질 때까지 대기
it('제출 후 모달이 닫힌다', async () => {
  render(<Modal />);
  await user.click(screen.getByRole('button', { name: '제출' }));

  await waitForElementToBeRemoved(() =>
    screen.queryByRole('dialog')
  );

  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

6. 컴포넌트 테스트 실전 예제

폼 컴포넌트 테스트

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

describe('LoginForm', () => {
  const user = userEvent.setup();

  it('빈 폼 제출 시 유효성 에러를 표시한다', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.click(screen.getByRole('button', { name: '로그인' }));

    expect(screen.getByText('이메일을 입력하세요')).toBeInTheDocument();
    expect(screen.getByText('비밀번호를 입력하세요')).toBeInTheDocument();
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it('잘못된 이메일 형식 입력 시 에러를 표시한다', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText('이메일'), 'invalid-email');
    await user.tab();  // 포커스 이동으로 blur 트리거

    expect(screen.getByText('올바른 이메일 형식이 아닙니다')).toBeInTheDocument();
  });

  it('올바른 정보 입력 시 onSubmit을 호출한다', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText('이메일'),    'hong@example.com');
    await user.type(screen.getByLabelText('비밀번호'), 'password123');
    await user.click(screen.getByRole('button', { name: '로그인' }));

    expect(onSubmit).toHaveBeenCalledWith({
      email:    'hong@example.com',
      password: 'password123',
    });
  });
});

비동기 데이터 패칭 컴포넌트 테스트

// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

// API 모킹 (vi.mock 또는 MSW)
vi.mock('./api', () => ({
  fetchUser: vi.fn(),
}));

import { fetchUser } from './api';

describe('UserProfile', () => {
  it('로딩 상태를 표시한다', () => {
    fetchUser.mockReturnValue(new Promise(() => {}));  // 영원히 pending
    render(<UserProfile userId={1} />);

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

  it('사용자 정보를 표시한다', async () => {
    fetchUser.mockResolvedValue({
      id: 1, name: '홍길동', email: 'hong@example.com', role: 'admin',
    });

    render(<UserProfile userId={1} />);

    expect(await screen.findByText('홍길동')).toBeInTheDocument();
    expect(screen.getByText('hong@example.com')).toBeInTheDocument();
    expect(screen.getByText('관리자')).toBeInTheDocument();
  });

  it('에러 상태를 표시한다', async () => {
    fetchUser.mockRejectedValue(new Error('네트워크 오류'));

    render(<UserProfile userId={1} />);

    expect(await screen.findByText('데이터를 불러올 수 없습니다')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '다시 시도' })).toBeInTheDocument();
  });
});

7. 커스텀 훅 테스트

// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('초기값으로 시작한다', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increment가 count를 1 증가시킨다', () => {
    const { result } = renderHook(() => useCounter(0));

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('props 변경을 처리한다', () => {
    const { result, rerender } = renderHook(
      ({ initialCount }) => useCounter(initialCount),
      { initialProps: { initialCount: 0 } }
    );

    expect(result.current.count).toBe(0);

    rerender({ initialCount: 10 });
    expect(result.current.count).toBe(10);
  });
});

8. Context와 Provider 테스트

// ThemeProvider.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';

// 테스트용 래퍼 컴포넌트
function ThemeDisplay() {
  const { theme, toggleTheme } = useTheme();
  return (
    <div>
      <span data-testid="theme">{theme}</span>
      <button onClick={toggleTheme}>테마 전환</button>
    </div>
  );
}

describe('ThemeContext', () => {
  const renderWithProvider = (ui: React.ReactElement) =>
    render(<ThemeProvider>{ui}</ThemeProvider>);

  it('기본 테마는 light다', () => {
    renderWithProvider(<ThemeDisplay />);
    expect(screen.getByTestId('theme')).toHaveTextContent('light');
  });

  it('테마 전환 버튼이 동작한다', async () => {
    renderWithProvider(<ThemeDisplay />);
    await userEvent.click(screen.getByRole('button', { name: '테마 전환' }));
    expect(screen.getByTestId('theme')).toHaveTextContent('dark');
  });
});

// 커스텀 render 함수 — Provider 자동 주입
function customRender(ui: React.ReactElement, options = {}) {
  return render(ui, {
    wrapper: ({ children }) => (
      <ThemeProvider>
        <AuthProvider>
          {children}
        </AuthProvider>
      </ThemeProvider>
    ),
    ...options,
  });
}

9. MSW로 API 모킹

Mock Service Worker(MSW) 는 실제 HTTP 요청을 인터셉트해 모킹합니다. vi.mock보다 현실적인 테스트가 가능합니다.

npm install --save-dev msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    return HttpResponse.json({
      id: Number(id),
      name: '홍길동',
      email: 'hong@example.com',
    });
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    if (body.email === 'hong@example.com' && body.password === 'correct') {
      return HttpResponse.json({ token: 'mock-jwt-token' });
    }
    return HttpResponse.json(
      { message: '이메일 또는 비밀번호가 틀렸습니다' },
      { status: 401 }
    );
  }),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// src/setupTests.ts
import { server } from './mocks/server';

beforeAll(() =>  server.listen({ onUnhandledRequest: 'error' }));
afterEach(() =>  server.resetHandlers());
afterAll(() =>   server.close());
// UserProfile.test.tsx — MSW 사용
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';

it('API 에러 시 에러 메시지를 표시한다', async () => {
  // 이 테스트에서만 에러 핸들러로 오버라이드
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json({ message: 'Server Error' }, { status: 500 });
    })
  );

  render(<UserProfile userId={1} />);
  expect(await screen.findByText('데이터를 불러올 수 없습니다')).toBeInTheDocument();
});

10. 결론 — 좋은 RTL 테스트의 원칙

해야 할 것

// ✅ 접근성 쿼리 우선 사용
screen.getByRole('button', { name: '제출' })
screen.getByLabelText('이메일')

// ✅ 사용자 행동으로 테스트
await user.type(input, '홍길동');
await user.click(button);

// ✅ 결과(텍스트, 역할, 상태)로 단언
expect(screen.getByText('성공!')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('에러 메시지');

하지 말아야 할 것

// ❌ 구현 세부사항 테스트
const { state } = component;  // 내부 상태 직접 접근

// ❌ CSS 클래스로 요소 선택
document.querySelector('.btn-primary');
container.firstChild;

// ❌ getByTestId 남발 (최후 수단)
screen.getByTestId('submit-btn');  // 접근성 쿼리로 대체 가능한지 먼저 확인

// ❌ act() 경고 무시
// act() 경고는 항상 수정해야 할 신호

jest-dom 매처 활용

expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('텍스트');
expect(element).toHaveValue('입력값');
expect(element).toHaveAttribute('aria-expanded', 'true');
expect(element).toHaveFocus();
expect(element).toBeChecked();
expect(element).toHaveClass('active');
expect(element).toHaveStyle({ color: 'red' });

한 줄 요약: RTL의 핵심은 "사용자가 볼 수 있는 것"과 "사용자가 할 수 있는 것"만 테스트하는 것입니다. 내부 구현이 바뀌어도 사용자 경험이 유지된다면 테스트가 깨져서는 안 됩니다.


이전 글: Vitest — Vite 네이티브 테스트 프레임워크
다음 글: Vue Test Utils — Vue 컴포넌트 테스트의 공식 도구

반응형

댓글