React 테스트는 "컴포넌트를 뜯어보기"가 아니다
지금까지 우리는 함수, 비동기 코드, 모듈, 시간, 스냅샷과 커버리지까지 Jest의 여러 기능을 살펴보았습니다. 이제 많은 프론트엔드 개발자가 가장 자주 마주치는 장면으로 들어가 보겠습니다. 바로 React 컴포넌트 테스트입니다.
React 컴포넌트 테스트를 처음 시작하면 이런 유혹에 빠지기 쉽습니다.
"이 컴포넌트의 useState 값이 잘 바뀌는지 확인해야 하나?"
"버튼을 누르면 setCount가 호출되는지 검사해야 하나?"
"컴포넌트 내부 함수가 몇 번 실행됐는지 봐야 하나?"
하지만 React Testing Library를 사용하는 테스트의 핵심은 조금 다릅니다. 컴포넌트 안쪽을 들여다보는 대신, 사용자가 실제로 보는 화면과 사용자가 실제로 하는 행동을 기준으로 테스트합니다. 버튼을 누른 뒤 내부 state가 1이 되었는지를 직접 검사하는 것이 아니라, 화면에 1이 보이는지를 검사합니다. 입력창 state가 어떤 객체로 저장되었는지를 확인하는 것이 아니라, 사용자가 입력한 값이 화면과 제출 결과에 제대로 반영되는지를 확인합니다.
이 관점은 단순히 멋진 철학이 아닙니다. 테스트를 오래 살아남게 만드는 실무적인 기준입니다. 컴포넌트 내부 구현은 자주 바뀝니다. useState를 useReducer로 바꿀 수도 있고, 컴포넌트를 여러 개로 쪼갤 수도 있고, 상태 관리 라이브러리를 도입할 수도 있습니다. 하지만 사용자 입장에서 "로그인 버튼을 누르면 환영 메시지가 보인다"는 동작은 그대로일 수 있습니다. 좋은 테스트는 이런 바깥쪽 동작을 붙잡습니다.
React Testing Library는 바로 이 방향으로 테스트를 작성하도록 돕는 도구입니다. Jest가 테스트를 실행하고 expect로 결과를 검증하는 역할을 한다면, React Testing Library는 React 컴포넌트를 테스트용 DOM에 렌더링하고, 그 안에서 사용자가 찾을 법한 방식으로 요소를 찾고, 사용자 행동을 흉내 내는 역할을 합니다. Jest 공식 문서에서도 React 컴포넌트의 DOM 테스트 예시로 @testing-library/react를 소개합니다.1

jsdom은 브라우저가 아니라 브라우저 흉내쟁이다
React 컴포넌트를 테스트하려면 화면이 필요합니다. 컴포넌트는 결국 <button>, <input>, <h1> 같은 DOM 요소를 만들기 때문입니다. 그런데 Jest 테스트는 보통 Chrome이나 Safari 같은 실제 브라우저에서 실행되지 않습니다. Jest는 Node.js 환경에서 빠르게 테스트를 실행하고, jsdom이라는 도구를 통해 document, window 같은 브라우저 API를 흉내 냅니다. Create React App 문서도 Jest가 Node 기반 러너이며, jsdom이 브라우저 전역 객체를 제공하지만 실제 브라우저 동작의 근사치라고 설명합니다.2
이 말은 중요합니다. jsdom은 진짜 브라우저가 아니기 때문에 CSS 레이아웃, 실제 픽셀 위치, 브라우저별 렌더링 차이까지 완벽하게 검증하는 데 적합하지 않습니다. 예를 들어 "이 버튼이 정확히 오른쪽에서 24px 떨어져 있는가?" 같은 테스트는 Jest와 jsdom보다 브라우저 기반 E2E 테스트 도구가 더 어울립니다.
반대로 jsdom은 이런 테스트에 아주 잘 맞습니다.
컴포넌트가 특정 텍스트를 보여 주는가
버튼을 클릭하면 화면이 바뀌는가
입력창에 값을 입력하고 제출하면 결과가 나타나는가
로딩이 끝난 뒤 데이터가 렌더링되는가
비활성화된 버튼이 정말 클릭할 수 없는 상태인가
즉, React 컴포넌트 테스트에서 우리는 "브라우저 화면을 완벽하게 재현한다"기보다 "사용자가 인식하는 DOM 구조와 행동을 빠르게 검증한다"고 이해하면 됩니다.
필요한 도구 한 번에 보기
React 컴포넌트 테스트에서 자주 함께 쓰는 도구는 다음과 같습니다.
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom@testing-library/react는 render, screen 같은 핵심 도구를 제공합니다. render는 컴포넌트를 테스트용 DOM에 올려 주고, screen은 렌더링된 화면 전체에서 요소를 찾게 해 줍니다.
@testing-library/user-event는 클릭, 입력, 선택 같은 사용자 행동을 실제 사용 방식에 가깝게 흉내 냅니다. 예전 예제에서는 fireEvent.click()을 많이 볼 수 있지만, 요즘은 가능한 한 user-event를 먼저 선택하는 편이 좋습니다.
@testing-library/jest-dom은 DOM을 검증하기 쉬운 matcher를 추가합니다. 예를 들어 toBeInTheDocument, toHaveTextContent, toBeDisabled 같은 표현을 사용할 수 있습니다. Create React App 문서 역시 React Testing Library와 함께 jest-dom을 사용하면 DOM 검증이 개선된다고 설명합니다.3
일반적으로 프로젝트의 테스트 설정 파일에 다음 한 줄을 추가합니다.
// src/setupTests.js 또는 jest.setup.js
import '@testing-library/jest-dom';그러면 각 테스트 파일에서 별도로 import하지 않아도 DOM 전용 matcher를 사용할 수 있습니다.
첫 번째 컴포넌트 테스트: 화면에 보이는 것을 검증하기
가장 단순한 컴포넌트부터 시작해 보겠습니다.
// Greeting.jsx
export default function Greeting({ name }) {
return <h2>안녕하세요, {name}님!</h2>;
}이 컴포넌트의 테스트는 다음처럼 작성할 수 있습니다.
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
describe('Greeting', () => {
it('사용자 이름이 포함된 인사말을 보여준다', () => {
render(<Greeting name="지민" />);
expect(
screen.getByRole('heading', { name: '안녕하세요, 지민님!' })
).toBeInTheDocument();
});
});여기서 흐름은 간단합니다. render(<Greeting name="지민" />)로 컴포넌트를 테스트 DOM에 렌더링합니다. 그다음 screen.getByRole('heading', { name: ... })로 화면에서 heading 역할을 가진 요소를 찾습니다. 마지막으로 expect(...).toBeInTheDocument()를 사용해 그 요소가 문서 안에 존재하는지 검증합니다.
초보자라면 getByText('안녕하세요, 지민님!')가 더 쉬워 보일 수 있습니다. 물론 텍스트로 찾는 것도 자주 사용합니다. 하지만 가능하다면 getByRole을 먼저 떠올리는 습관을 들이는 것이 좋습니다. 사용자는 버튼, 링크, 제목, 입력창 같은 의미 있는 요소를 기준으로 화면을 이해하고, 스크린 리더 같은 보조 기술도 이런 역할 정보를 활용합니다. 테스트가 역할을 기준으로 요소를 찾으면 접근성에 좋은 마크업을 작성하도록 자연스럽게 유도됩니다.
render와 screen: "올리고, 화면에서 찾는다"
React Testing Library 테스트를 읽을 때 가장 자주 보게 되는 두 단어가 render와 screen입니다.
render(<컴포넌트 />);
const button = screen.getByRole('button', { name: '저장' });render는 컴포넌트를 테스트 환경에 그립니다. 실제 브라우저 탭을 여는 것은 아니지만, jsdom이 제공하는 document.body 안에 컴포넌트가 만든 DOM을 넣습니다.
screen은 현재 테스트 화면 전체를 가리키는 객체라고 생각하면 됩니다. 예전 방식의 예제에서는 const { getByText } = render(...)처럼 render 결과에서 쿼리 함수를 꺼내 쓰는 경우도 많았습니다. 하지만 screen.getByText(...)처럼 쓰면 "화면에서 찾는다"는 의도가 더 선명해집니다.
다음 컴포넌트를 보겠습니다.
// SaveButton.jsx
export default function SaveButton({ disabled = false }) {
return <button disabled={disabled}>저장</button>;
}테스트는 이렇게 쓸 수 있습니다.
import { render, screen } from '@testing-library/react';
import SaveButton from './SaveButton';
describe('SaveButton', () => {
it('저장 버튼을 보여준다', () => {
render(<SaveButton />);
expect(screen.getByRole('button', { name: '저장' })).toBeInTheDocument();
});
it('disabled가 true이면 버튼이 비활성화된다', () => {
render(<SaveButton disabled />);
expect(screen.getByRole('button', { name: '저장' })).toBeDisabled();
});
});toBeDisabled()는 jest-dom이 제공하는 matcher입니다. expect(button.disabled).toBe(true)라고 써도 검증은 가능하지만, toBeDisabled()가 테스트 의도를 훨씬 더 잘 드러냅니다. 테스트 코드는 기계만 읽는 코드가 아니라, 미래의 나와 동료가 읽을 설명서이기도 합니다.
내부 state 대신 사용자 결과를 테스트하기
이번에는 버튼을 누르면 숫자가 증가하는 카운터를 테스트해 보겠습니다.
// Counter.jsx
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<section>
<p>현재 값: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
</section>
);
}이 컴포넌트의 중요한 동작은 무엇일까요? 내부적으로 setCount가 호출되는 것이 아닙니다. 사용자 입장에서는 버튼을 눌렀을 때 숫자가 올라가는 것이 중요합니다.
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter', () => {
it('증가 버튼을 클릭하면 화면의 숫자가 1 증가한다', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('현재 값: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '증가' }));
expect(screen.getByText('현재 값: 1')).toBeInTheDocument();
});
});이 테스트는 useState를 전혀 언급하지 않습니다. count라는 변수명도 모릅니다. setCount가 어떻게 호출되는지도 검사하지 않습니다. 그럼에도 컴포넌트가 사용자에게 제공해야 하는 핵심 동작은 제대로 검증합니다.
나중에 이 컴포넌트를 다음처럼 바꾼다고 상상해 보세요.
const [value, setValue] = useState(0);변수명을 count에서 value로 바꿔도 사용자가 보는 화면과 행동이 같다면 테스트는 깨지지 않아야 합니다. 이것이 구현 세부사항보다 사용자 결과를 테스트해야 하는 이유입니다.
getByRole, getByText, queryBy, findBy의 차이
React Testing Library를 처음 배울 때 가장 헷갈리는 부분이 쿼리 이름입니다. getByText, queryByText, findByText는 비슷해 보이지만 실패 방식과 기다림 방식이 다릅니다.
먼저 getBy*는 "지금 당장 있어야 하는 요소"를 찾을 때 사용합니다. 요소가 없으면 즉시 테스트가 실패합니다.
expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();이 코드는 로그인 버튼이 렌더링 직후 바로 있어야 한다는 뜻입니다. 버튼이 없다면 테스트는 실패합니다.
queryBy*는 "없어야 하는 요소"를 검사할 때 사용합니다. 요소가 없어도 에러를 던지지 않고 null을 반환합니다.
expect(screen.queryByText('에러가 발생했습니다')).not.toBeInTheDocument();여기서 getByText를 쓰면 어떻게 될까요? 에러 메시지가 없을 때 getByText 자체가 먼저 실패해 버립니다. "없어야 한다"를 검증하려면 queryBy*가 자연스럽습니다.
findBy*는 "비동기적으로 나중에 나타날 요소"를 찾을 때 사용합니다. Promise를 반환하므로 await과 함께 씁니다.
expect(await screen.findByText('저장이 완료되었습니다')).toBeInTheDocument();이 코드는 저장 완료 메시지가 바로 없더라도 일정 시간 동안 기다립니다. API 응답 뒤에 나타나는 문구, 타이머 후에 표시되는 메시지, 비동기 상태 변경 뒤의 UI를 검증할 때 유용합니다.
이 차이를 작은 컴포넌트로 익혀 보겠습니다.
// Notice.jsx
import { useState } from 'react';
export default function Notice() {
const [message, setMessage] = useState('');
const handleClick = () => {
setTimeout(() => {
setMessage('알림이 도착했습니다');
}, 300);
};
return (
<div>
<button onClick={handleClick}>알림 받기</button>
{message && <p>{message}</p>}
</div>
);
}테스트는 다음처럼 작성할 수 있습니다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Notice from './Notice';
describe('Notice', () => {
it('처음에는 알림이 없고, 버튼을 누르면 나중에 알림을 보여준다', async () => {
const user = userEvent.setup();
render(<Notice />);
expect(screen.queryByText('알림이 도착했습니다')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '알림 받기' }));
expect(await screen.findByText('알림이 도착했습니다')).toBeInTheDocument();
});
});처음 상태에서는 메시지가 없어야 하므로 queryByText를 씁니다. 버튼은 즉시 있어야 하므로 getByRole을 씁니다. 클릭 후 메시지는 setTimeout 뒤에 나타나므로 findByText를 씁니다.
이렇게 쿼리를 고르는 기준이 생기면 테스트가 훨씬 읽기 쉬워집니다. 테스트 코드를 읽는 사람도 "아, 이 요소는 바로 있어야 하는구나", "이 메시지는 없어야 하는구나", "이 문구는 나중에 나타나는구나"를 바로 이해할 수 있습니다.
요소는 사용자가 찾는 방식으로 찾는다
React Testing Library에서 요소를 찾는 방법은 여러 가지입니다. 하지만 모든 방법이 같은 가치를 갖지는 않습니다. 우선순위를 생각하면 테스트 품질이 좋아집니다.
가장 먼저 떠올릴 것은 getByRole입니다. 버튼, 링크, 제목, 체크박스, 텍스트박스처럼 의미 있는 역할을 가진 요소를 찾을 수 있습니다.
screen.getByRole('button', { name: '제출' });
screen.getByRole('heading', { name: '회원가입' });
screen.getByRole('checkbox', { name: '약관에 동의합니다' });입력 요소는 label을 기준으로 찾는 방식도 매우 좋습니다.
screen.getByLabelText('이메일');
screen.getByLabelText('비밀번호');이 방식은 테스트뿐 아니라 실제 접근성에도 도움이 됩니다. 사용자가 "이메일"이라는 레이블을 보고 입력창의 의미를 이해하듯, 테스트도 같은 단서를 사용합니다.
텍스트 자체가 중요한 경우에는 getByText를 사용합니다.
screen.getByText('가입이 완료되었습니다');반면 data-testid는 마지막 수단에 가깝게 생각하는 편이 좋습니다. 물론 복잡한 그래픽 요소나 텍스트가 없는 아이콘 버튼처럼 다른 방식으로 찾기 어려운 경우에는 유용합니다. 하지만 처음부터 모든 요소에 data-testid를 붙이면 테스트가 사용자 관점에서 멀어질 수 있습니다.
다음 예를 보겠습니다.
<button data-testid="submit-button">제출</button>이 버튼은 이렇게 찾기보다,
screen.getByTestId('submit-button');다음처럼 찾는 편이 더 좋습니다.
screen.getByRole('button', { name: '제출' });두 코드는 같은 버튼을 찾지만 의미가 다릅니다. 아래 코드는 사용자가 실제로 인식하는 "제출 버튼"을 찾습니다. 위 코드는 개발자가 붙인 테스트용 이름을 찾습니다. 테스트가 사용자에게 가까울수록 리팩터링에 강해집니다.
user-event로 실제 사용자 행동에 가깝게 테스트하기
이제 입력 폼을 테스트해 보겠습니다. 로그인 폼 컴포넌트가 있다고 가정하겠습니다.
// LoginForm.jsx
import { useState } from 'react';
export default function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
if (!email.includes('@')) {
setError('올바른 이메일을 입력하세요');
return;
}
if (password.length < 6) {
setError('비밀번호는 6자 이상이어야 합니다');
return;
}
setError('');
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit} aria-label="로그인 폼">
{error && <p role="alert">{error}</p>}
<label htmlFor="email">이메일</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button type="submit">로그인</button>
</form>
);
}이 컴포넌트는 내부 state가 여러 개입니다. 하지만 테스트는 state를 직접 확인하지 않습니다. 사용자가 입력하고, 버튼을 누르고, 결과를 확인합니다.
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
it('올바른 이메일과 비밀번호를 입력하면 제출 핸들러를 호출한다', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('이메일'), '[email protected]');
await user.type(screen.getByLabelText('비밀번호'), 'secret123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'secret123',
});
});
it('이메일 형식이 올바르지 않으면 에러 메시지를 보여준다', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'wrong-email');
await user.type(screen.getByLabelText('비밀번호'), 'secret123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'올바른 이메일을 입력하세요'
);
expect(handleSubmit).not.toHaveBeenCalled();
});
});여기서 userEvent.setup()을 만들고 await user.type(...), await user.click(...)처럼 사용했습니다. user-event의 많은 동작은 실제 사용자 상호작용에 가깝게 여러 이벤트를 순서대로 발생시키기 때문에 비동기로 다루는 것이 안전합니다.
예를 들어 사람이 입력창에 abc를 입력하면 단순히 값만 바뀌는 것이 아닙니다. 포커스가 이동하고, 키보드 이벤트가 발생하고, input 이벤트가 발생합니다. user-event는 이런 흐름에 더 가깝게 동작하려고 합니다. 그래서 일반적인 클릭, 입력, 선택 테스트에서는 fireEvent보다 user-event를 먼저 선택하는 것이 좋습니다.
fireEvent는 언제 쓸까?
그렇다고 fireEvent가 쓸모없다는 뜻은 아닙니다. fireEvent는 더 낮은 수준의 DOM 이벤트를 직접 발생시킵니다. 사용자의 자연스러운 행동을 표현하기보다는 특정 이벤트 하나를 정확히 발생시키고 싶을 때 사용할 수 있습니다.
예를 들어 스크롤 이벤트, 애니메이션 종료 이벤트, 드래그 관련 특수 이벤트처럼 user-event로 표현하기 애매한 경우가 있습니다.
import { fireEvent, render, screen } from '@testing-library/react';
it('스크롤 이벤트가 발생하면 안내 문구를 보여준다', () => {
render(<ScrollNotice />);
fireEvent.scroll(window, { target: { scrollY: 500 } });
expect(screen.getByText('아래로 이동했습니다')).toBeInTheDocument();
});일상적인 버튼 클릭은 다음처럼 쓰는 편이 더 자연스럽습니다.
await user.click(screen.getByRole('button', { name: '삭제' }));반면 특정 DOM 이벤트를 직접 다뤄야 할 때는 fireEvent를 사용할 수 있습니다.
핵심은 우선순위입니다. 사용자의 행동을 테스트한다면 user-event를 먼저 떠올리고, 정말 낮은 수준의 이벤트 제어가 필요할 때 fireEvent를 꺼내면 됩니다.
jest-dom matcher로 테스트 의도를 선명하게 만들기
React 컴포넌트 테스트에서는 DOM 상태를 검증하는 일이 많습니다. @testing-library/jest-dom을 설정해 두면 이런 검증을 자연스러운 문장처럼 작성할 수 있습니다.
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(message).toHaveTextContent('저장 완료');
expect(input).toHaveValue('hello');
expect(checkbox).toBeChecked();이 표현들은 단순히 짧아서 좋은 것이 아닙니다. 테스트가 무엇을 확인하는지 정확히 말해 줍니다.
다음 버튼 컴포넌트를 보겠습니다.
// SubmitButton.jsx
export default function SubmitButton({ isSaving }) {
return (
<button disabled={isSaving}>
{isSaving ? '저장 중...' : '저장하기'}
</button>
);
}테스트는 이렇게 쓸 수 있습니다.
import { render, screen } from '@testing-library/react';
import SubmitButton from './SubmitButton';
describe('SubmitButton', () => {
it('저장 중이 아니면 저장하기 버튼을 보여준다', () => {
render(<SubmitButton isSaving={false} />);
const button = screen.getByRole('button', { name: '저장하기' });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it('저장 중이면 버튼을 비활성화하고 저장 중 문구를 보여준다', () => {
render(<SubmitButton isSaving />);
const button = screen.getByRole('button', { name: '저장 중...' });
expect(button).toBeDisabled();
expect(button).toHaveTextContent('저장 중...');
});
});이 테스트에는 컴포넌트 내부 구현을 설명하는 단어가 거의 없습니다. 대신 사용자가 보는 버튼 문구와 버튼 상태를 검증합니다.
비동기 UI 테스트: 로딩 후 나타나는 화면 검증하기
React 앱에서는 데이터를 가져온 뒤 화면이 바뀌는 일이 많습니다. 여기서는 복잡한 API mocking까지 깊게 들어가지는 않고, 비동기적으로 상태가 바뀌는 컴포넌트를 테스트하는 감각을 익혀 보겠습니다.
// Profile.jsx
import { useEffect, useState } from 'react';
export default function Profile({ loadUser }) {
const [user, setUser] = useState(null);
useEffect(() => {
loadUser().then((data) => setUser(data));
}, [loadUser]);
if (!user) {
return <p>불러오는 중...</p>;
}
return <h2>{user.name}님 환영합니다</h2>;
}이 컴포넌트는 처음에는 로딩 문구를 보여 주고, loadUser() Promise가 해결되면 환영 메시지를 보여 줍니다.
// Profile.test.jsx
import { render, screen } from '@testing-library/react';
import Profile from './Profile';
describe('Profile', () => {
it('사용자 정보를 불러온 뒤 환영 메시지를 보여준다', async () => {
const loadUser = jest.fn().mockResolvedValue({ name: '수아' });
render(<Profile loadUser={loadUser} />);
expect(screen.getByText('불러오는 중...')).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: '수아님 환영합니다' }))
.toBeInTheDocument();
expect(loadUser).toHaveBeenCalledTimes(1);
});
});여기서 핵심은 findByRole입니다. 환영 메시지는 렌더링 직후에 바로 나타나지 않습니다. Promise가 해결되고 React state가 업데이트된 뒤 나타납니다. 그래서 getByRole이 아니라 await screen.findByRole(...)을 사용합니다.
이 패턴은 실제 프로젝트에서 자주 만납니다. 목록을 불러오는 컴포넌트, 검색 결과를 보여 주는 컴포넌트, 저장 후 완료 메시지를 띄우는 컴포넌트 모두 비슷한 방식으로 테스트할 수 있습니다.
테스트 구조는 describe, it, expect로 읽히게 만든다
Jest 테스트 파일은 보통 describe, it 또는 test, expect로 구성합니다. 이미 앞 장들에서 익숙해졌겠지만, React 컴포넌트 테스트에서는 이 구조가 특히 "사용자 시나리오"를 설명하는 문장처럼 읽히면 좋습니다.
describe('LoginForm', () => {
it('올바른 정보를 입력하면 로그인 요청을 보낸다', async () => {
// ...
});
it('이메일이 비어 있으면 에러 메시지를 보여준다', async () => {
// ...
});
it('제출 중에는 로그인 버튼을 비활성화한다', async () => {
// ...
});
});테스트 이름은 실패했을 때 가장 먼저 보게 되는 힌트입니다. "works"나 "renders"처럼 막연한 이름보다 "무엇을 하면 어떤 결과가 나와야 하는지"를 적는 편이 좋습니다.
테스트 본문은 보통 다음 흐름으로 정리하면 읽기 쉽습니다.
it('증가 버튼을 클릭하면 숫자가 증가한다', async () => {
// Arrange: 준비
const user = userEvent.setup();
render(<Counter />);
// Act: 행동
await user.click(screen.getByRole('button', { name: '증가' }));
// Assert: 검증
expect(screen.getByText('현재 값: 1')).toBeInTheDocument();
});주석을 항상 써야 하는 것은 아닙니다. 짧은 테스트에서는 오히려 주석이 방해될 수 있습니다. 하지만 머릿속으로는 늘 "준비, 행동, 검증"의 흐름을 의식하면 테스트가 덜 뒤엉킵니다.
깨지기 쉬운 테스트와 오래가는 테스트
마지막으로 같은 기능을 나쁜 방식과 좋은 방식으로 비교해 보겠습니다. 다음 컴포넌트가 있습니다.
export default function TodoItem({ todo, onToggle }) {
return (
<li>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={onToggle}
/>
{todo.title}
</label>
</li>
);
}깨지기 쉬운 테스트는 구현 구조에 강하게 묶입니다.
// 아쉬운 예
const { container } = render(<TodoItem todo={todo} onToggle={onToggle} />);
const checkbox = container.querySelector('li > label > input');이 테스트는 DOM 구조가 조금만 바뀌어도 깨질 수 있습니다. 예를 들어 <label> 안에 <span>을 추가하거나, <li> 안쪽 구조를 정리하는 리팩터링만 해도 선택자가 실패할 수 있습니다. 사용자는 그런 내부 구조를 신경 쓰지 않는데 테스트만 예민하게 반응하는 셈입니다.
더 나은 테스트는 사용자가 인식하는 이름과 역할을 사용합니다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoItem from './TodoItem';
describe('TodoItem', () => {
it('체크박스를 클릭하면 완료 상태 변경 함수를 호출한다', async () => {
const user = userEvent.setup();
const onToggle = jest.fn();
render(
<TodoItem
todo={{ title: 'Jest 공부하기', done: false }}
onToggle={onToggle}
/>
);
await user.click(screen.getByRole('checkbox', { name: 'Jest 공부하기' }));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});이 테스트는 컴포넌트 내부 태그 구조에 덜 민감합니다. 체크박스가 있고, 그 체크박스가 "Jest 공부하기"라는 이름으로 인식되며, 사용자가 클릭했을 때 완료 상태 변경이 일어난다는 핵심 행동에 집중합니다.
React 컴포넌트 테스트에서 오래가는 테스트를 쓰고 싶다면 다음 질문을 자주 던져 보세요.
"이 검증은 사용자가 관심 있어 하는 결과인가?"
"이 요소를 사용자는 어떤 이름이나 역할로 찾을까?"
"내부 구현을 바꿔도 같은 동작이면 테스트가 통과해야 하지 않을까?"
이 질문들이 테스트를 더 단단하게 만들어 줍니다.
이번 장에서 기억할 것
React 컴포넌트 테스트는 내부 state를 감시하는 작업이 아닙니다. 사용자가 보는 화면과 사용자가 하는 행동을 검증하는 작업입니다. Jest는 테스트를 실행하고 결과를 판단하는 기반을 제공하고, React Testing Library는 컴포넌트를 렌더링한 뒤 사용자 관점으로 DOM을 탐색하고 상호작용하도록 도와줍니다.
render로 컴포넌트를 올리고 screen으로 화면에서 요소를 찾습니다. 바로 있어야 하는 요소는 getBy*, 없어야 하는 요소는 queryBy*, 나중에 나타나는 요소는 findBy*를 사용합니다. 요소는 가능하면 getByRole, getByLabelText, getByText처럼 사용자가 인식하는 단서를 기준으로 찾습니다.
클릭과 입력은 가능하면 user-event로 표현합니다. fireEvent는 더 낮은 수준의 DOM 이벤트가 꼭 필요할 때 사용하면 됩니다. 그리고 @testing-library/jest-dom의 matcher를 활용하면 DOM 검증을 더 읽기 쉬운 문장으로 만들 수 있습니다.
좋은 React 테스트는 컴포넌트의 속마음을 캐묻지 않습니다. 대신 사용자의 눈과 손을 빌려 이렇게 묻습니다.
"화면에 보여야 할 것이 보이는가?"
"사용자가 행동했을 때 기대한 변화가 일어나는가?"
이 기준을 지키면 리팩터링에도 쉽게 흔들리지 않는 테스트를 작성할 수 있습니다. 그리고 바로 그때 테스트는 귀찮은 숙제가 아니라, 마음 놓고 코드를 바꿀 수 있게 해 주는 안전망이 됩니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
