RTL 은 작은 거 세 개 줘: 컴포넌트를 DOM 에 mount 하는 render() 함수, 렌더된 DOM 을 쿼리하는 screen 객체, 그리고 어떤 종류 쿼리를 선호해야 하는지에 대한 관례 세트. 의도적으로 작아 — shallow render 없음, 인스턴스 접근 없음, 내부 state 탐사 없음. 전제: 너의 테스트가 사용자 (그리고 assistive tech) 가 하듯 DOM 쿼리하면, 진짜 버그 잡고 진짜 refactor 에서 살아남아.
RTL 은 @testing-library/jest-dom 와 짝지어서 toBeInTheDocument(), toHaveClass(), toBeDisabled() 같은 단언, 그리고 @testing-library/user-event 와 짝지어서 현실적 인터랙션 (다음 lesson).
설치
패키지 세 개가 React 쪽 커버. 더해서 lesson 2.1 Vitest 설치에서 온 happy-dom 이 RTL 쿼리가 돌아갈 DOM 제공.
Setup 파일
vitest.setup.ts 에 두 가지 들어가:
import '@testing-library/jest-dom/vitest' — 커스텀 matcher (toBeInTheDocument 등) 를 전역으로 노출.
@testing-library/react 의 cleanup() 호출하는 afterEach — 테스트 사이에 컴포넌트 unmount 해서 서로 새지 않게. vitest.config 에 `globals: true` 설정했거나 RTL v15+ 면 cleanup 자동 — 근데 테스트 격리가 중요하면 명시가 암묵보다 안전해.
사용자가 보는 거 테스트, 컴포넌트가 가진 거 말고. 테스트가 prop, state 값, ref 에 손 뻗으면 물어봐: 사용자가 그게 존재한다는 걸 알까? 아니면 너의 테스트는 구현에 과적합. 가시 DOM 쿼리하도록 다시 짜.
첫 컴포넌트 테스트
골격은 균일: render(<Component />), 그 다음 screen.getByX(...) 로 element 찾기, 그 다음 expect(element).toBeInTheDocument() 같은 거. 테스트를 이야기로 읽어 — 사용자가 페이지 열고, 헤딩 보고, 버튼 클릭하고, 새 state 봄.
Setup 의 흔한 함정
Matcher 안 로드됨: toBeInTheDocument is not a function 은 vitest.setup.ts 에서 '@testing-library/jest-dom/vitest' import 잊은 거.
테스트가 서로 오염: 한 테스트의 쿼리가 이전 테스트가 렌더한 element 발견. afterEach 에 명시적 cleanup() 추가 (또는 RTL 업그레이드 — v15+ 자동 cleanup).
CSS-in-JS 에러: happy-dom 이 모든 CSS 관련 API 를 완전히 구현 안 해. 컴포넌트가 렌더에서 CSS 에러로 크래시하면, 그 파일만 환경을 jsdom 으로 — 파일 상단에 // @vitest-environment jsdom.
Code
설치 — RTL 패키지 세 개·bash
# RTL + matchers + user-event
npm install -D \
@testing-library/react \
@testing-library/jest-dom \
@testing-library/user-event
# happy-dom should already be installed from the Vitest setup lesson
vitest.setup.ts — matcher + cleanup·typescript
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Unmount React trees between tests so queries don't see stale DOM.
afterEach(() => {
cleanup();
});
대상 컴포넌트·tsx
// src/components/greeting.tsx
export function Greeting({ name }: { name: string }) {
return (
<section aria-labelledby="greeting-heading">
<h2 id="greeting-heading">Welcome, {name}.</h2>
<p>Glad you made it.</p>
</section>
);
}
첫 RTL 테스트 — render, query, assert·tsx
// src/components/greeting.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Greeting } from './greeting';
describe('<Greeting />', () => {
it('renders the heading with the user name', () => {
render(<Greeting name="Pippa" />);
const heading = screen.getByRole('heading', { name: /welcome, pippa/i });
expect(heading).toBeInTheDocument();
});
it('renders the supporting text', () => {
render(<Greeting name="Pippa" />);
expect(screen.getByText(/glad you made it/i)).toBeInTheDocument();
});
});
<UserCard user={{ name, email, online }} /> 컴포넌트 만들어 — 이름을 헤딩에, 이메일을 링크로 (mailto:), online === true 면 'Online' 배지. screen.getByRole 써서 세 테스트 짜: (1) 헤딩이 이름과 함께 나타남, (2) 이메일 링크가 맞는 href 가짐, (3) 배지는 online 이 true 일 때만 나타남 (부재 케이스엔 queryByText 써서 throw 대신 null 반환).
Hint
(3) 은 queryByText('Online') 을 .not.toBeInTheDocument() 와 짝지어. getByText 쓰면 단언 도달 전에 throw — queryBy 가 부재 시 null 반환, 그게 not.toBeInTheDocument 가 기대하는 거야.
Progress
Progress is local-only — sign in to sync across devices.