이 완전한 가이드를 통해 React Testing Library(RTL)를 마스터하세요. 모범 사례와 실제 예제에 초점을 맞춰 React 애플리케이션을 위한 효과적이고 유지보수 가능하며 사용자 중심적인 테스트를 작성하는 방법을 배워보세요.
React Testing Library: 종합 가이드
오늘날 급변하는 웹 개발 환경에서 React 애플리케이션의 품질과 안정성을 보장하는 것은 매우 중요합니다. React Testing Library(RTL)는 사용자 관점에 초점을 맞춘 테스트를 작성하기 위한 인기 있고 효과적인 솔루션으로 부상했습니다. 이 가이드는 RTL에 대한 완전한 개요를 제공하며, 기본 개념부터 고급 기술까지 모든 것을 다루어 견고하고 유지보수 가능한 React 애플리케이션을 구축할 수 있도록 지원합니다.
React Testing Library를 선택해야 하는 이유
기존의 테스트 접근 방식은 종종 구현 세부 정보에 의존하여 테스트를 불안정하게 만들고 사소한 코드 변경에도 깨지기 쉽습니다. 반면 RTL은 사용자가 컴포넌트와 상호 작용하는 것처럼 테스트하여 사용자가 보고 경험하는 것에 초점을 맞추도록 권장합니다. 이 접근 방식은 다음과 같은 몇 가지 주요 이점을 제공합니다:
- 사용자 중심 테스트: RTL은 사용자 관점을 반영하는 테스트 작성을 촉진하여 최종 사용자 입장에서 애플리케이션이 예상대로 작동하는지 보장합니다.
- 테스트 취약성 감소: 구현 세부 정보를 테스트하지 않음으로써 RTL 테스트는 코드를 리팩토링할 때 깨질 가능성이 적어 유지보수성이 높고 견고한 테스트로 이어집니다.
- 코드 설계 개선: RTL은 접근성이 높고 사용하기 쉬운 컴포넌트 작성을 장려하여 전반적인 코드 설계를 개선합니다.
- 접근성에 대한 집중: RTL을 사용하면 컴포넌트의 접근성을 더 쉽게 테스트할 수 있어 모든 사람이 애플리케이션을 사용할 수 있도록 보장합니다.
- 단순화된 테스트 프로세스: RTL은 간단하고 직관적인 API를 제공하여 테스트를 더 쉽게 작성하고 유지보수할 수 있습니다.
테스트 환경 설정하기
RTL 사용을 시작하기 전에 테스트 환경을 설정해야 합니다. 여기에는 일반적으로 필요한 종속성 설치 및 테스트 프레임워크 구성이 포함됩니다.
사전 요구사항
- Node.js 및 npm (또는 yarn): 시스템에 Node.js와 npm (또는 yarn)이 설치되어 있는지 확인하세요. 공식 Node.js 웹사이트에서 다운로드할 수 있습니다.
- React 프로젝트: 기존 React 프로젝트가 있거나 Create React App 또는 유사한 도구를 사용하여 새 프로젝트를 만들어야 합니다.
설치
npm 또는 yarn을 사용하여 다음 패키지를 설치하세요:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react
또는 yarn 사용 시:
yarn add --dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react
패키지 설명:
- @testing-library/react: React 컴포넌트 테스트를 위한 핵심 라이브러리입니다.
- @testing-library/jest-dom: DOM 노드에 대한 단언을 위한 사용자 정의 Jest 매처를 제공합니다.
- Jest: 인기 있는 JavaScript 테스트 프레임워크입니다.
- babel-jest: Babel을 사용하여 코드를 컴파일하는 Jest 트랜스포머입니다.
- @babel/preset-env: 대상 환경을 지원하는 데 필요한 Babel 플러그인 및 프리셋을 결정하는 Babel 프리셋입니다.
- @babel/preset-react: React를 위한 Babel 프리셋입니다.
구성
프로젝트 루트에 다음 내용으로 `babel.config.js` 파일을 만드세요:
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react'],
};
`package.json` 파일을 업데이트하여 테스트 스크립트를 포함시키세요:
{
"scripts": {
"test": "jest"
}
}
프로젝트 루트에 Jest를 구성하기 위한 `jest.config.js` 파일을 만드세요. 최소한의 구성은 다음과 같을 수 있습니다:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['/src/setupTests.js'],
};
다음 내용으로 `src/setupTests.js` 파일을 만드세요. 이렇게 하면 모든 테스트에서 Jest DOM 매처를 사용할 수 있습니다:
import '@testing-library/jest-dom/extend-expect';
첫 테스트 작성하기
간단한 예제로 시작하겠습니다. 인사말 메시지를 표시하는 React 컴포넌트가 있다고 가정해 보겠습니다:
// src/components/Greeting.js
import React from 'react';
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
이제 이 컴포넌트에 대한 테스트를 작성해 보겠습니다:
// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('renders a greeting message', () => {
render(<Greeting name="World" />);
const greetingElement = screen.getByText(/Hello, World!/i);
expect(greetingElement).toBeInTheDocument();
});
설명:
- `render`: 이 함수는 컴포넌트를 DOM에 렌더링합니다.
- `screen`: 이 객체는 DOM을 쿼리하는 메서드를 제공합니다.
- `getByText`: 이 메서드는 텍스트 내용으로 요소를 찾습니다. `/i` 플래그는 검색을 대소문자를 구분하지 않도록 만듭니다.
- `expect`: 이 함수는 컴포넌트의 동작에 대한 단언을 하는 데 사용됩니다.
- `toBeInTheDocument`: 이 매처는 요소가 DOM에 존재하는지 단언합니다.
테스트를 실행하려면 터미널에서 다음 명령을 실행하세요:
npm test
모든 것이 올바르게 구성되었다면 테스트가 통과될 것입니다.
일반적인 RTL 쿼리
RTL은 DOM에서 요소를 찾기 위한 다양한 쿼리 메서드를 제공합니다. 이 쿼리들은 사용자가 애플리케이션과 상호 작용하는 방식을 모방하도록 설계되었습니다.
`getByRole`
이 쿼리는 ARIA 역할로 요소를 찾습니다. 접근성을 높이고 테스트가 기본 DOM 구조의 변경에 탄력적으로 대응하도록 하기 위해 가능하면 `getByRole`을 사용하는 것이 좋습니다.
<button role="button">Click me</button>
const buttonElement = screen.getByRole('button');
expect(buttonElement).toBeInTheDocument();
`getByLabelText`
이 쿼리는 연관된 레이블의 텍스트로 요소를 찾습니다. 폼 요소를 테스트하는 데 유용합니다.
<label htmlFor="name">Name:</label>
<input type="text" id="name" />
const nameInputElement = screen.getByLabelText('Name:');
expect(nameInputElement).toBeInTheDocument();
`getByPlaceholderText`
이 쿼리는 플레이스홀더 텍스트로 요소를 찾습니다.
<input type="text" placeholder="Enter your email" />
const emailInputElement = screen.getByPlaceholderText('Enter your email');
expect(emailInputElement).toBeInTheDocument();
`getByAltText`
이 쿼리는 이미지 요소의 alt 텍스트로 찾습니다. 접근성을 보장하기 위해 모든 이미지에 의미 있는 alt 텍스트를 제공하는 것이 중요합니다.
<img src="logo.png" alt="Company Logo" />
const logoImageElement = screen.getByAltText('Company Logo');
expect(logoImageElement).toBeInTheDocument();
`getByTitle`
이 쿼리는 title 속성으로 요소를 찾습니다.
<span title="Close">X</span>
const closeElement = screen.getByTitle('Close');
expect(closeElement).toBeInTheDocument();
`getByDisplayValue`
이 쿼리는 표시 값으로 요소를 찾습니다. 미리 채워진 값을 가진 폼 입력을 테스트하는 데 유용합니다.
<input type="text" value="Initial Value" />
const inputElement = screen.getByDisplayValue('Initial Value');
expect(inputElement).toBeInTheDocument();
`getAllBy*` 쿼리
`getBy*` 쿼리 외에도 RTL은 일치하는 요소의 배열을 반환하는 `getAllBy*` 쿼리도 제공합니다. 이는 동일한 특성을 가진 여러 요소가 DOM에 존재하는지 단언해야 할 때 유용합니다.
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(3);
`queryBy*` 쿼리
`queryBy*` 쿼리는 `getBy*` 쿼리와 유사하지만, 일치하는 요소를 찾지 못하면 오류를 던지는 대신 `null`을 반환합니다. 이는 요소가 DOM에 *존재하지 않음*을 단언하고 싶을 때 유용합니다.
const missingElement = screen.queryByText('Non-existent text');
expect(missingElement).toBeNull();
`findBy*` 쿼리
`findBy*` 쿼리는 `getBy*` 쿼리의 비동기 버전입니다. 일치하는 요소를 찾았을 때 해결되는 Promise를 반환합니다. 이는 API에서 데이터를 가져오는 것과 같은 비동기 작업을 테스트하는 데 유용합니다.
// Simulating an asynchronous data fetch
const fetchData = () => new Promise(resolve => {
setTimeout(() => resolve('Data Loaded!'), 1000);
});
function MyComponent() {
const [data, setData] = React.useState(null);
React.useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}
test('loads data asynchronously', async () => {
render(<MyComponent />);
const dataElement = await screen.findByText('Data Loaded!');
expect(dataElement).toBeInTheDocument();
});
사용자 상호작용 시뮬레이션
RTL은 버튼 클릭, 입력 필드에 타이핑, 폼 제출과 같은 사용자 상호작용을 시뮬레이션하기 위한 `fireEvent`와 `userEvent` API를 제공합니다.
`fireEvent`
`fireEvent`를 사용하면 프로그래밍 방식으로 DOM 이벤트를 트리거할 수 있습니다. 이는 발생하는 이벤트를 세밀하게 제어할 수 있는 저수준 API입니다.
<button onClick={() => alert('Button clicked!')}>Click me</button>
import { fireEvent } from '@testing-library/react';
test('simulates a button click', () => {
const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
render(<button onClick={() => alert('Button clicked!')}>Click me</button>);
const buttonElement = screen.getByRole('button');
fireEvent.click(buttonElement);
expect(alertMock).toHaveBeenCalledTimes(1);
alertMock.mockRestore();
});
`userEvent`
`userEvent`는 사용자 상호작용을 더 현실적으로 시뮬레이션하는 고수준 API입니다. 포커스 관리 및 이벤트 순서와 같은 세부 사항을 처리하여 테스트를 더 견고하고 덜 취약하게 만듭니다.
<input type="text" onChange={e => console.log(e.target.value)} />
import userEvent from '@testing-library/user-event';
test('simulates typing in an input field', () => {
const inputElement = screen.getByRole('textbox');
userEvent.type(inputElement, 'Hello, world!');
expect(inputElement).toHaveValue('Hello, world!');
});
비동기 코드 테스트하기
많은 React 애플리케이션에는 API에서 데이터를 가져오는 것과 같은 비동기 작업이 포함됩니다. RTL은 비동기 코드를 테스트하기 위한 여러 도구를 제공합니다.
`waitFor`
`waitFor`를 사용하면 단언을 하기 전에 조건이 참이 될 때까지 기다릴 수 있습니다. 완료하는 데 시간이 걸리는 비동기 작업을 테스트하는 데 유용합니다.
function MyComponent() {
const [data, setData] = React.useState(null);
React.useEffect(() => {
setTimeout(() => {
setData('Data loaded!');
}, 1000);
}, []);
return <div>{data}</div>;
}
import { waitFor } from '@testing-library/react';
test('waits for data to load', async () => {
render(<MyComponent />);
await waitFor(() => screen.getByText('Data loaded!'));
const dataElement = screen.getByText('Data loaded!');
expect(dataElement).toBeInTheDocument();
});
`findBy*` 쿼리
앞서 언급했듯이 `findBy*` 쿼리는 비동기적이며 일치하는 요소를 찾았을 때 해결되는 Promise를 반환합니다. 이는 DOM의 변경을 초래하는 비동기 작업을 테스트하는 데 유용합니다.
Hook 테스트하기
React Hook은 상태 저장 로직을 캡슐화하는 재사용 가능한 함수입니다. RTL은 `@testing-library/react-hooks`의 `renderHook` 유틸리티(v17부터는 `@testing-library/react`에 직접 포함되어 사용이 중단됨)를 제공하여 사용자 정의 Hook을 격리하여 테스트할 수 있습니다.
// src/hooks/useCounter.js
import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => {
setCount(prevCount => prevCount + 1);
};
const decrement = () => {
setCount(prevCount => prevCount - 1);
};
return { count, increment, decrement };
}
export default useCounter;
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('increments the counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
설명:
- `renderHook`: 이 함수는 Hook을 렌더링하고 Hook의 결과를 포함하는 객체를 반환합니다.
- `act`: 이 함수는 상태 업데이트를 유발하는 모든 코드를 감싸는 데 사용됩니다. 이는 React가 업데이트를 올바르게 일괄 처리하고 처리하도록 보장합니다.
고급 테스트 기법
RTL의 기본을 마스터했다면, 테스트의 품질과 유지보수성을 향상시키기 위해 더 고급 테스트 기법을 탐색할 수 있습니다.
모듈 모킹(Mocking)
때로는 외부 모듈이나 종속성을 모의(mock)하여 컴포넌트를 격리하고 테스트 중 동작을 제어해야 할 수 있습니다. Jest는 이를 위한 강력한 모킹 API를 제공합니다.
// src/api/dataService.js
export const fetchData = async () => {
const response = await fetch('/api/data');
const data = await response.json();
return data;
};
// src/components/MyComponent.js
import React, { useState, useEffect } from 'react';
import { fetchData } from '../api/dataService';
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}
// src/components/MyComponent.test.js
import { render, screen, waitFor } from '@testing-library/react';
import MyComponent from './MyComponent';
import * as dataService from '../api/dataService';
jest.mock('../api/dataService');
test('fetches data from the API', async () => {
dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' });
render(<MyComponent />);
await waitFor(() => screen.getByText('Mocked data!'));
expect(screen.getByText('Mocked data!')).toBeInTheDocument();
expect(dataService.fetchData).toHaveBeenCalledTimes(1);
});
설명:
- `jest.mock('../api/dataService')`: 이 줄은 `dataService` 모듈을 모킹합니다.
- `dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' })`: 이 줄은 모킹된 `fetchData` 함수가 지정된 데이터로 해결되는 Promise를 반환하도록 구성합니다.
- `expect(dataService.fetchData).toHaveBeenCalledTimes(1)`: 이 줄은 모킹된 `fetchData` 함수가 한 번 호출되었는지 단언합니다.
Context Provider
컴포넌트가 Context Provider에 의존하는 경우, 테스트 중에 컴포넌트를 Provider로 감싸야 합니다. 이렇게 하면 컴포넌트가 컨텍스트 값에 접근할 수 있습니다.
// src/contexts/ThemeContext.js
import React, { createContext, useState } from 'react';
export const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// src/components/MyComponent.js
import React, { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div style={{ backgroundColor: theme === 'light' ? '#fff' : '#000', color: theme === 'light' ? '#000' : '#fff' }}>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// src/components/MyComponent.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import MyComponent from './MyComponent';
import { ThemeProvider } from '../contexts/ThemeContext';
test('toggles the theme', () => {
render(
<ThemeProvider>
<MyComponent />
</ThemeProvider>
);
const themeParagraph = screen.getByText(/Current theme: light/i);
const toggleButton = screen.getByRole('button', { name: /Toggle Theme/i });
expect(themeParagraph).toBeInTheDocument();
fireEvent.click(toggleButton);
expect(screen.getByText(/Current theme: dark/i)).toBeInTheDocument();
});
설명:
- `MyComponent`를 `ThemeProvider`로 감싸서 테스트 중에 필요한 컨텍스트를 제공합니다.
Router로 테스트하기
React Router를 사용하는 컴포넌트를 테스트할 때는 모의 Router 컨텍스트를 제공해야 합니다. 이는 `react-router-dom`의 `MemoryRouter` 컴포넌트를 사용하여 달성할 수 있습니다.
// src/components/MyComponent.js
import React from 'react';
import { Link } from 'react-router-dom';
function MyComponent() {
return (
<div>
<Link to="/about">Go to About Page</Link>
</div>
);
}
// src/components/MyComponent.test.js
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import MyComponent from './MyComponent';
test('renders a link to the about page', () => {
render(
<MemoryRouter>
<MyComponent />
</MemoryRouter>
);
const linkElement = screen.getByRole('link', { name: /Go to About Page/i });
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', '/about');
});
설명:
- `MyComponent`를 `MemoryRouter`로 감싸서 모의 Router 컨텍스트를 제공합니다.
- 링크 요소에 올바른 `href` 속성이 있는지 단언합니다.
효과적인 테스트 작성을 위한 모범 사례
RTL로 테스트를 작성할 때 따라야 할 몇 가지 모범 사례는 다음과 같습니다:
- 사용자 상호작용에 집중하기: 사용자가 애플리케이션과 상호작용하는 방식을 시뮬레이션하는 테스트를 작성하세요.
- 구현 세부 정보 테스트 피하기: 컴포넌트의 내부 작동을 테스트하지 마세요. 대신, 관찰 가능한 동작에 집중하세요.
- 명확하고 간결한 테스트 작성하기: 테스트를 이해하고 유지보수하기 쉽게 만드세요.
- 의미 있는 테스트 이름 사용하기: 테스트 중인 동작을 정확하게 설명하는 테스트 이름을 선택하세요.
- 테스트 격리 유지하기: 테스트 간의 종속성을 피하세요. 각 테스트는 독립적이고 자체적으로 완결되어야 합니다.
- 엣지 케이스 테스트하기: '해피 패스'만 테스트하지 마세요. 엣지 케이스와 오류 조건도 테스트해야 합니다.
- 코드를 작성하기 전에 테스트 작성하기: 테스트 주도 개발(TDD)을 사용하여 코드를 작성하기 전에 테스트를 작성하는 것을 고려해 보세요.
- 'AAA' 패턴 따르기: 준비(Arrange), 실행(Act), 단언(Assert). 이 패턴은 테스트를 구조화하고 가독성을 높이는 데 도움이 됩니다.
- 테스트를 빠르게 유지하기: 느린 테스트는 개발자들이 자주 실행하는 것을 꺼리게 할 수 있습니다. 네트워크 요청을 모킹하고 DOM 조작량을 최소화하여 테스트 속도를 최적화하세요.
- 설명적인 오류 메시지 사용하기: 단언이 실패했을 때, 오류 메시지는 실패의 원인을 신속하게 식별할 수 있는 충분한 정보를 제공해야 합니다.
결론
React Testing Library는 React 애플리케이션을 위한 효과적이고 유지보수 가능하며 사용자 중심적인 테스트를 작성하기 위한 강력한 도구입니다. 이 가이드에서 설명한 원칙과 기법을 따르면 사용자의 요구를 충족하는 견고하고 신뢰할 수 있는 애플리케이션을 구축할 수 있습니다. 사용자 관점에서 테스트하는 데 집중하고, 구현 세부 정보를 테스트하는 것을 피하며, 명확하고 간결한 테스트를 작성하는 것을 기억하세요. RTL을 도입하고 모범 사례를 채택함으로써, 현재 위치나 글로벌 고객의 특정 요구사항에 관계없이 React 프로젝트의 품질과 유지보수성을 크게 향상시킬 수 있습니다.