테스팅 강좌 #3 React Testing Library — 사용자처럼 본다
#2 가 순수 함수 한 개를 다뤘다면, 이번 글은 컴포넌트를 다룹니다. React Testing Library(이하 RTL)가 들어옵니다.
테스팅 강좌에서 이번 글의 위치:
- #1 왜 테스트인가
- #2 Vitest 셋업과 첫 단위 테스트
- #3 React Testing Library — 사용자처럼 본다 ← 이번 글
- #4 비동기와 네트워크 모킹 — MSW
- #5 사용자 이벤트와 폼 테스트
- #6 Playwright로 E2E와 CI 통합
이번 글은 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/react—render, queries,cleanup등.@testing-library/dom— 위의 의존성 (자동 설치되지만 명시).@testing-library/jest-dom—toBeInTheDocument,toBeVisible같은 DOM 친화 matcher 들.@testing-library/user-event—userEvent.click(),userEvent.type()같은 사용자 이벤트 시뮬레이션. (#5 의 본격 주제.)
vitest.setup.ts에 jest-dom 활성화:
import '@testing-library/jest-dom/vitest';이 한 줄이면 매 테스트에서 toBeInTheDocument() 같은 매처가 자동으로 추가됩니다.
vitest.config.ts에서 environment: 'jsdom'이 켜져 있는지 한 번 확인. 컴포넌트 테스트는 jsdom이 필수입니다.
첫 컴포넌트 테스트 — Greeting #
가장 단순한 컴포넌트.
type Props = { name?: string };
export function Greeting({ name = 'World' }: Props) {
return <h1>Hello, {name}!</h1>;
}테스트:
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가 있습니다. 외울 필요 없고, 우선순위 가이드가 있어서 그걸 따라가면 됩니다.
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을 가지고 있습니다.
<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"
찾기:
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 htmlFor="email">이메일</label>
<input id="email" type="email" />
// 또는
<label>
이메일
<input type="email" />
</label>screen.getByLabelText('이메일');getByRole('textbox', { name: '이메일' })와 같은 결과를 주지만, getByLabelText가 의도가 더 명확. 폼 테스트의 첫 손길.
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로 찾기.
<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 | 에러 throw | Promise (async) |
복수형(*All*)도 있습니다 — getAllByRole, findAllByText 등.
각각의 역할:
expect(screen.getByRole('button', { name: '저장' })).toBeInTheDocument();
// 못 찾으면 즉시 에러 — 의도가 명확
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// getByRole 을 쓰면 못 찾을 때 에러가 나서 검증 자체가 안 됨
await screen.findByRole('alert');
// 비동기로 나타나는 토스트, 에러 메시지 등
자주 하는 실수:
- “버튼이 화면에 없는지” 를 검증할 때
getByRole을 쓰면 — 버튼이 없을 때 에러로 테스트가 즉시 실패.queryBy*를 써야 함. - 비동기 결과를 동기 query로 찾으면 — “찾으려는 시점엔 아직 렌더가 안 됨”.
findBy*또는waitFor(#4).
클릭 한 번 — 카운터 #
두 번째 컴포넌트.
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>
);
}테스트:
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개 다 잡혀서 에러. 메인 영역의 버튼만 보고 싶다면:
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 검증이 자연스러워져요.
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()
#
테스트가 깨졌는데 왜인지 모르겠을 때.
render(<MyComponent />);
screen.debug(); // 콘솔에 현재 DOM 트리 출력
또는 특정 요소만:
const el = screen.getByRole('button');
screen.debug(el);또 하나의 강력한 도구 — screen.logTestingPlaygroundURL(). 콘솔에 URL이 출력되고, 열면 Testing Playground 가 현재 DOM을 시각화해서 어떤 query를 쓰면 좋을지 추천해 줍니다.
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를 만드는 게 정석:
// 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 우선순위:
getByRole→getByLabelText→getByText→ … →getByTestId(마지막 수단). getBy*(있어야 함, 동기) /queryBy*(없어도 됨, 동기) /findBy*(잠시 후 나타남, 비동기) 의 역할 분리.userEvent를fireEvent보다 우선. 매 테스트에서userEvent.setup()한 번.jest-dom매처(toBeInTheDocument,toBeVisible,toHaveValue…) 는 셋업하면 강력.- 막히면
screen.debug()/screen.logTestingPlaygroundURL()두 도구.
다음 글(#4 비동기와 네트워크 모킹)에서는 컴포넌트가 데이터를 fetch 하는 경우입니다. findBy*와 waitFor의 역할, 그리고 MSW로 네트워크 레이어를 가로채는 패턴. 가장 자주 쓰이지만 가장 자주 함정에 빠지는 부분입니다.