테스팅 강좌 #3 React Testing Library — 사용자처럼 본다

#2 가 순수 함수 한 개를 다뤘다면, 이번 글은 컴포넌트를 다룹니다. React Testing Library(이하 RTL)가 들어옵니다.

테스팅 강좌에서 이번 글의 위치:

이번 글은 RTL의 철학과 가장 자주 쓰는 queries만. 사용자 이벤트와 폼은 #5 의 몫이니 여기서는 클릭 한두 번 정도만 짚습니다.

RTL의 철학 — “사용자처럼 본다” #

#1 에서 짚은 결을 한 번 더. RTL의 핵심 슬로건은:

The more your tests resemble the way your software is used, the more confidence they can give you.

번역하면 **“테스트가 실제 사용 방식에 가까울수록, 그게 주는 확신도 크다.”**이 한 문장에서 모든 결정이 흘러나옵니다.

  • 컴포넌트 내부 state를 보지 않음. → 사용자는 보지 못하니까.
  • DOM의 id/className으로 요소를 찾지 않음. → 사용자는 그것을 모르니까.
  • 대신 role / label / 화면에 보이는 텍스트로 요소를 찾음. → 사용자가 화면을 인식하는 방식.

이게 자연스럽게 접근성(a11y) 친화적인 코드를 만듭니다. screen reader가 보는 방식과 RTL이 보는 방식이 같으니까요.

셋업 — 한 번만 #

#2 에서 만든 vitest + react 프로젝트 위에 추가 패키지를 깝니다.

설치
pnpm add -D @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event

각각의 역할:

  • @testing-library/reactrender, queries, cleanup 등.
  • @testing-library/dom — 위의 의존성 (자동 설치되지만 명시).
  • @testing-library/jest-domtoBeInTheDocument, toBeVisible 같은 DOM 친화 matcher 들.
  • @testing-library/user-eventuserEvent.click(), userEvent.type() 같은 사용자 이벤트 시뮬레이션. (#5 의 본격 주제.)

vitest.setup.ts에 jest-dom 활성화:

vitest.setup.ts
import '@testing-library/jest-dom/vitest';

이 한 줄이면 매 테스트에서 toBeInTheDocument() 같은 매처가 자동으로 추가됩니다.

vitest.config.ts에서 environment: 'jsdom'이 켜져 있는지 한 번 확인. 컴포넌트 테스트는 jsdom이 필수입니다.

첫 컴포넌트 테스트 — Greeting #

가장 단순한 컴포넌트.

src/components/Greeting.tsx
type Props = { name?: string };

export function Greeting({ name = 'World' }: Props) {
  return <h1>Hello, {name}!</h1>;
}

테스트:

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

describe('Greeting', () => {
  it('이름을 주면 그 이름을 띄운다', () => {
    render(<Greeting name="Alice" />);
    expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
  });

  it('이름을 안 주면 World 를 띄운다', () => {
    render(<Greeting />);
    expect(screen.getByRole('heading')).toHaveTextContent('Hello, World!');
  });
});

세 가지 새 친구가 등장했습니다.

  • render — 컴포넌트를 jsdom 안의 DOM에 마운트.
  • screen — 마운트된 DOM 위에서 queries를 호출하는 객체. 모든 queries는 screen.에서 시작한다는 컨벤션 (예전에는 render의 반환값에서 꺼냈지만, screen이 더 깔끔).
  • getByRole('heading') — 화면에서 “역할이 heading 인 요소” 를 찾음. <h1> ~ <h6>가 자동으로 그 role을 가져요.

toHaveTextContent는 jest-dom의 매처. 텍스트 노드 내용 확인.

render 후 별도 cleanup이 필요 없습니다. RTL이 vitest의 afterEach에 cleanup을 자동 등록합니다.

Queries — 어떤 것부터 쓸까 #

RTL 에는 수십 개의 queries가 있습니다. 외울 필요 없고, 우선순위 가이드가 있어서 그걸 따라가면 됩니다.

Testing Library 우선순위
1. 모두에게 보이는 요소 (접근성 트리)
   1.1  getByRole          ← 거의 항상 첫 번째
   1.2  getByLabelText      ← 폼 input
   1.3  getByPlaceholderText
   1.4  getByText
   1.5  getByDisplayValue

2. semantic queries
   2.1  getByAltText        ← img
   2.2  getByTitle           ← title 속성

3. test-id (마지막 수단)
   3.1  getByTestId         ← data-testid 속성

위에서부터 시도해 보고 안 되면 아래로. 거의 모든 경우가 1.1 ~ 1.5 안에서 풀려요.

getByRole — 가장 먼저 #

WAI-ARIA의 role로 요소를 찾습니다. HTML 요소들은 암묵적인 role을 가지고 있습니다.

암묵적 role
<button>저장</button>          // role="button"
<a href="..."></a>            // role="link"
<input type="checkbox" />       // role="checkbox"
<h1>제목</h1>                   // role="heading"
<nav>...</nav>                  // role="navigation"
<main>...</main>                // role="main"

찾기:

getByRole 예시
screen.getByRole('button', { name: '저장' });       // 텍스트가 '저장' 인 버튼
screen.getByRole('link', { name: '홈' });
screen.getByRole('checkbox', { name: '동의' });
screen.getByRole('heading', { level: 1 });          // h1 만
screen.getByRole('textbox', { name: '이메일' });    // input[type="text"|"email"]

{ name: ... }의 의미가 미묘합니다. role이 button 인 요소가 여러 개일 때 “accessible name이 ‘저장’ 인 것” 으로 좁힘. accessible name은:

  • 버튼/링크라면 그 안의 텍스트.
  • input 이라면 연결된 <label>의 텍스트, 또는 aria-label.
  • 이미지라면 alt.

이게 곧 screen reader가 읽는 이름과 같습니다. 접근성 친화적인 코드를 짜면 자연스럽게 테스트하기도 쉬워집니다.

getByLabelText — 폼 input #

폼 input은 거의 항상 이걸로 찾습니다.

label + input
<label htmlFor="email">이메일</label>
<input id="email" type="email" />

// 또는

<label>
  이메일
  <input type="email" />
</label>
찾기
screen.getByLabelText('이메일');

getByRole('textbox', { name: '이메일' })와 같은 결과를 주지만, getByLabelText가 의도가 더 명확. 폼 테스트의 첫 손길.

getByText — 그냥 텍스트 #

화면에 보이는 텍스트로 찾을 때.

getByText
screen.getByText('환영합니다');                      // 정확히 일치
screen.getByText(/환영/);                             // 정규식
screen.getByText((content) => content.startsWith('환영')); // 함수

단, getByText컨테이너 요소(<div>)도 잡힐 수 있습니다. 뉘앙스가 살짝 미묘 — <div>환영합니다</div> 면 div가 잡히고, <div>환영<strong>합니다</strong></div> 면 텍스트가 두 자식에 갈라져 있어서 getByText('환영합니다')가 안 잡혀요.

data-testid — 정말 마지막 수단 #

위 queries로 도저히 못 잡을 때만 data-testid="..." 속성을 추가하고 getByTestId로 찾기.

testid
<div data-testid="cart-total">$42.50</div>
찾기
screen.getByTestId('cart-total');

testid는 사용자에게 보이지 않는 속성입니다 — RTL의 철학과 정확히 반대 방향. 그래서 마지막 수단. 테스트의 50% 가 testid 라면 컴포넌트의 접근성 자체를 의심해야 합니다.

getBy / queryBy / findBy — 셋의 역할 #

같은 query가 세 가지 prefix로 나뉩니다. 헷갈리는 첫 부분입니다.

못 찾으면여러 개 찾으면비동기
getBy*에러 throw에러 throw동기
queryBy*null 반환에러 throw동기
findBy*1초간 기다린 뒤 throw에러 throwPromise (async)

복수형(*All*)도 있습니다 — getAllByRole, findAllByText 등.

각각의 역할:

getBy — 있어야 하는 것
expect(screen.getByRole('button', { name: '저장' })).toBeInTheDocument();
// 못 찾으면 즉시 에러 — 의도가 명확
queryBy — 없어야 하는 것
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// getByRole 을 쓰면 못 찾을 때 에러가 나서 검증 자체가 안 됨
findBy — 잠시 후 나타나는 것
await screen.findByRole('alert');
// 비동기로 나타나는 토스트, 에러 메시지 등

자주 하는 실수:

  • “버튼이 화면에 없는지” 를 검증할 때 getByRole을 쓰면 — 버튼이 없을 때 에러로 테스트가 즉시 실패. queryBy*를 써야 함.
  • 비동기 결과를 동기 query로 찾으면 — “찾으려는 시점엔 아직 렌더가 안 됨”. findBy* 또는 waitFor (#4).

클릭 한 번 — 카운터 #

두 번째 컴포넌트.

src/components/Counter.tsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p aria-live="polite">현재 카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(0)}>reset</button>
    </div>
  );
}

테스트:

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

describe('Counter', () => {
  it('+1 버튼을 누르면 카운트가 1 증가한다', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    expect(screen.getByText('현재 카운트: 0')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('현재 카운트: 1')).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '+1' }));
    expect(screen.getByText('현재 카운트: 2')).toBeInTheDocument();
  });

  it('reset 버튼을 누르면 0 으로 돌아간다', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: '+1' }));
    await user.click(screen.getByRole('button', { name: 'reset' }));

    expect(screen.getByText('현재 카운트: 0')).toBeInTheDocument();
  });
});

새 query들:

  • userEvent.setup()userEvent의 인스턴스 생성. 매 테스트에서 한 번씩 setup() 하는 게 권장 패턴.
  • await user.click(...) — userEvent의 모든 동작이 비동기. await로 받아야 다음 검증이 정확.

userEvent를 쓰는 이유 — fireEvent보다 실제 사용자 동작에 가깝습니다. 클릭 하나가 사실 mousedown → mouseup → click의 시퀀스인데, fireEvent.click은 그중 click만 발사합니다. userEvent.click은 셋 다 발사하고 focus 변화도 시뮬레이션합니다. (#5 에서 깊이.)

aria-live와 동적 업데이트 #

위 Counter의 <p aria-live="polite">가 흥미로운 부분입니다. 이건 screen reader에게 “이 텍스트가 바뀌면 사용자에게 알리세요” 라고 표시하는 속성. getByRole('status')로 찾을 수도 있습니다 (aria-live는 status role의 일부).

이런 부분에서 RTL과 접근성이 자연스럽게 만나요. 테스트하기 좋은 컴포넌트는 보통 접근성도 좋습니다.

within — 특정 영역 안에서만 찾기 #

큰 화면에서 일부분만 검사할 때.

복잡한 화면
<>
  <header>
    <button>로그아웃</button>
  </header>
  <main>
    <button>저장</button>
    <button>삭제</button>
  </main>
  <footer>
    <button>도움말</button>
  </footer>
</>

이 화면에서 getByRole('button')을 쓰면 4개 다 잡혀서 에러. 메인 영역의 버튼만 보고 싶다면:

within
import { within } from '@testing-library/react';

const main = screen.getByRole('main');
const saveButton = within(main).getByRole('button', { name: '저장' });

within(element)는 그 element 안에서만 찾는 새 query 객체를 만들어 줍니다.

jest-dom 매처 — 자주 쓰는 것 #

@testing-library/jest-dom이 추가해 주는 매처들. DOM 검증이 자연스러워져요.

자주 쓰는 jest-dom
expect(el).toBeInTheDocument();
expect(el).toBeVisible();
expect(el).toBeDisabled();
expect(el).toBeEnabled();
expect(el).toBeChecked();        // checkbox
expect(el).toHaveValue('text');  // input
expect(el).toHaveTextContent('hello');
expect(el).toHaveClass('btn-primary');
expect(el).toHaveAttribute('href', '/about');
expect(el).toHaveFocus();
expect(el).toBeRequired();
expect(el).toHaveAccessibleName('저장');

둘이 헷갈리는 부분:

  • toBeInTheDocument() — DOM 트리에 붙어 있는가.
  • toBeVisible() — 거기에 더해 display: none 등이 아닌가.

UI가 조건부로 숨김/표시되는 경우에는 toBeVisible이 더 정확합니다.

디버깅 — screen.debug() #

테스트가 깨졌는데 왜인지 모르겠을 때.

현재 DOM 출력
render(<MyComponent />);
screen.debug();  // 콘솔에 현재 DOM 트리 출력

또는 특정 요소만:

요소만
const el = screen.getByRole('button');
screen.debug(el);

또 하나의 강력한 도구 — screen.logTestingPlaygroundURL(). 콘솔에 URL이 출력되고, 열면 Testing Playground 가 현재 DOM을 시각화해서 어떤 query를 쓰면 좋을지 추천해 줍니다.

Playground URL
screen.logTestingPlaygroundURL();
// "Open this URL in your browser: https://testing-playground.com/#..."

queries를 어떻게 짤지 막힐 때 이게 답입니다.

흔한 함정 #

act 경고가 자주 뜬다 — userEvent를 쓰면 거의 안 마주침. fireEvent를 쓸 때나 직접 state setter를 부를 때 발생. 거의 항상 비동기 변화를 await 안 한 게 원인.

getByText가 안 잡힘 — 텍스트가 여러 노드에 갈라져 있을 가능성. screen.debug()로 실제 DOM 확인. 함수 형태의 matcher 또는 정규식이 답일 때가 많음.

같은 query가 여러 개 잡힘getAllBy*로 바꾸거나 name 옵션으로 좁히기. data-testid로 도망가지 말 것.

테스트가 prod와 다르게 도는 것 같음<StrictMode>, <Suspense>, theme provider 같은 wrapping이 빠진 경우. 커스텀 render를 만드는 게 정석:

custom render
// src/test/utils.tsx
import { render as rtlRender } from '@testing-library/react';
import { ThemeProvider } from '@/theme';

export function render(ui: React.ReactElement) {
  return rtlRender(ui, {
    wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
  });
}

export * from '@testing-library/react';

이후 테스트에서 import { render, screen } from '@/test/utils'로 사용.

정리 #

  • RTL의 한 문장 — “테스트가 실제 사용 방식에 가까울수록 확신이 크다.”
  • 컴포넌트는 render로 마운트하고, queries는 screen에서 시작합니다.
  • queries 우선순위: getByRolegetByLabelTextgetByText → … → getByTestId (마지막 수단).
  • getBy* (있어야 함, 동기) / queryBy* (없어도 됨, 동기) / findBy* (잠시 후 나타남, 비동기) 의 역할 분리.
  • userEventfireEvent보다 우선. 매 테스트에서 userEvent.setup() 한 번.
  • jest-dom 매처(toBeInTheDocument, toBeVisible, toHaveValue …) 는 셋업하면 강력.
  • 막히면 screen.debug() / screen.logTestingPlaygroundURL() 두 도구.

다음 글(#4 비동기와 네트워크 모킹)에서는 컴포넌트가 데이터를 fetch 하는 경우입니다. findBy*waitFor의 역할, 그리고 MSW로 네트워크 레이어를 가로채는 패턴. 가장 자주 쓰이지만 가장 자주 함정에 빠지는 부분입니다.

X