📑 목차
- 테스트, 왜 해야 할까요? (feat. Jest와 RTL)
- 왜 테스트를 해야 할까요?
- Jest & React Testing Library 환경 설정, 어렵지 않아요!
- 새로운 React 프로젝트에 설정하기
- 첫 번째 테스트 작성: 간단한 컴포넌트부터 시작해요!
- 예시 컴포넌트: Button.js
- 테스트 파일 작성: Button.test.js
- RTL의 쿼리 우선순위 팁
- 비동기 처리와 Mocking: 네트워크 요청도 문제없어요!
- 예시 컴포넌트: UserList.js (API 호출)
- 테스트 파일 작성: UserList.test.js
- Mocking 심화: jest.mock과 jest.fn()
- 비동기 쿼리: findBy*와 waitFor
- 상태 관리와 Context API 컴포넌트 테스트하기
- 예시 컴포넌트: ThemeToggle.js (Context API 사용)
- 테스트 파일 작성: ThemeToggle.test.js
- `wrapper` 옵션 활용
- 사용자 정의 훅(Custom Hook) 테스트, 이렇게 하세요!
- 예시 훅: useCounter.js
- 테스트 파일 작성: useCounter.test.js
- `renderHook`과 `act`의 중요성
- 테스트 코드 작성 팁과 모범 사례
- 1. "사용자 관점"에서 테스트하기 (RTL의 핵심 철학)
- 2. 테스트의 범위 설정: 유닛, 통합, E2E
- 3. Mocking은 신중하게, 하지만 필요할 땐 과감하게
- 4. 접근성을 고려한 쿼리 사용
- 5. 테스트 커버리지 (Test Coverage) 활용
- 6. 테스트 클린업 (Cleanup)
- 마무리하며: 견고한 UI의 시작, 테스트
Image by analogicus on Pixabay
테스트, 왜 해야 할까요? (feat. Jest와 RTL)
안녕하세요, 개발자 여러분! 오늘 다룰 이야기는 React 컴포넌트 테스트입니다. 혹시 이런 경험 없으신가요? 분명 어제까지 잘 동작하던 기능인데, 새로운 기능을 추가하거나 리팩토링하다 보니 갑자기 엉뚱한 곳에서 버그가 터지는 상황 말이죠. "이 부분은 건드리지 않았는데 왜..." 하면서 밤샘 디버깅을 하셨던 기억, 저만 있는 건 아닐 거예요. 😂
바로 이런 상황을 방지하고, 우리의 소중한 개발 시간을 지켜주는 것이 테스트 코드랍니다. 특히 사용자에게 직접 보이는 React 컴포넌트는 다양한 상태와 사용자 인터랙션에 따라 복잡하게 동작하기 때문에, 테스트의 중요성이 더욱 커지죠.
이 글에서는 React 컴포넌트 테스트의 두 기둥이라고 할 수 있는 Jest와 React Testing Library (RTL)를 함께 활용하여, 실제로 React 컴포넌트를 어떻게 테스트하는지 실전 예제와 함께 자세히 알려드릴 거예요. 이 둘은 왜 함께 쓰이는 걸까요? 간단히 설명해 드릴게요.
- Jest: JavaScript 테스트 프레임워크의 '엔진'이라고 생각하시면 돼요. 테스트를 실행하고, 결과를 보고하고, 코드를 Mocking하는 등 전반적인 테스트 환경을 제공하죠. '이 코드가 이렇게 동작할 거야!'라는 우리의 기대를
expect와 다양한 매처(matcher)를 통해 검증하게 해줍니다. - React Testing Library (RTL): Jest 위에서 React 컴포넌트를 좀 더 '사용자 관점'에서 테스트할 수 있도록 도와주는 유틸리티 라이브러리입니다. DOM 요소를 쿼리하고, 사용자 이벤트를 시뮬레이션하는 등의 기능을 제공하죠. 마치 실제 사용자가 브라우저에서 컴포넌트를 사용하는 것처럼 테스트를 작성하도록 유도하는 것이 핵심 철학이랍니다.
결론적으로 Jest는 테스트를 실행하는 환경을 제공하고, RTL은 React 컴포넌트의 DOM을 다루고 사용자처럼 상호작용할 수 있게 해주는 도구라고 보시면 돼요. 이 둘이 만나면 정말 강력한 시너지를 발휘하죠!
왜 테스트를 해야 할까요?
테스트 코드를 작성하는 것은 단순히 버그를 찾는 것 이상의 가치를 제공합니다. 몇 가지 핵심적인 장점을 살펴볼까요?
- 버그 감소 및 안정성 향상: 가장 명확한 이유죠. 테스트 코드는 우리의 코드가 예상대로 동작하는지 지속적으로 검증하여, 새로운 변경사항이 기존 기능을 망가뜨리는 회귀(regression) 버그를 조기에 발견하고 수정할 수 있도록 돕습니다.
- 리팩토링 용이성 증대: 테스트 코드가 있다면, 마음 편하게 코드를 개선하고 구조를 변경할 수 있어요. 리팩토링 후에도 테스트를 돌려보기만 하면, 기능이 제대로 동작하는지 바로 확인할 수 있으니까요. 마치 안전망 위에서 작업하는 것과 같죠.
- 코드 품질 향상 및 문서화 효과: 테스트를 염두에 두고 코드를 작성하면, 자연스럽게 모듈화가 잘 되고 결합도가 낮은 코드를 만들게 됩니다. 또한, 테스트 코드 자체가 특정 기능이 어떻게 동작해야 하는지를 보여주는 훌륭한 문서 역할도 한답니다.
- 협업 및 유지보수 효율성 증대: 팀원들이 작성한 코드의 의도를 테스트 코드를 통해 파악하기 쉽고, 새로운 개발자가 프로젝트에 합류했을 때도 테스트 코드를 통해 빠르게 코드 베이스를 이해할 수 있습니다.
Jest와 RTL의 주요 역할을 표로 비교해 볼게요.
| 구분 | Jest | React Testing Library (RTL) |
|---|---|---|
| 주요 역할 | 테스트 실행 환경 제공, 매처(matcher) 제공, Mocking 기능, 테스트 보고 | React 컴포넌트 렌더링, DOM 요소 쿼리, 사용자 이벤트 시뮬레이션, 접근성 고려 |
| 철학/목표 | JavaScript 코드의 정확성 검증 | 사용자가 컴포넌트와 상호작용하는 방식과 동일하게 테스트 (구현 디테일보다 사용자 경험 중시) |
| 예시 기능 | expect().toBe(), jest.fn(), jest.mock() |
render(), screen.getByRole(), fireEvent.click() |
어때요? 이제 이 둘이 왜 찰떡궁합인지 조금 이해가 가시죠? 그럼 이제 본격적으로 테스트 환경을 설정하고 코드를 작성해 볼까요?
Jest & React Testing Library 환경 설정, 어렵지 않아요!
React 프로젝트에 Jest와 RTL을 설정하는 것은 생각보다 간단합니다. 대부분의 경우, Create React App (CRA)이나 Vite 같은 도구로 프로젝트를 시작하면 기본적으로 Jest와 RTL이 포함되어 있거나, 최소한 설정하기 쉽게 되어 있거든요.
새로운 React 프로젝트에 설정하기
만약 새롭게 프로젝트를 시작한다면, 다음과 같이 진행할 수 있습니다. (기존 프로젝트라면 설치 부분만 참고하세요!)
1. 프로젝트 생성 (CRA 또는 Vite)
Create React App을 사용한다면 이미 Jest와 RTL이 설치되어 있을 거예요. 만약 Vite를 사용한다면 다음과 같이 프로젝트를 생성합니다.
# Vite + React 프로젝트 생성
npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
2. 필요한 패키지 설치
Vite 프로젝트나 Jest/RTL이 없는 기존 프로젝트라면, 다음 패키지들을 설치해야 해요.
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @babel/preset-env @babel/preset-react babel-jest
# 타입스크립트를 사용한다면 추가로 설치
npm install --save-dev @types/jest
jest: 테스트 프레임워크@testing-library/react: React 컴포넌트 테스트를 위한 RTL@testing-library/jest-dom: Jest에서 DOM 관련 추가 매처들을 사용할 수 있게 해주는 라이브러리 (예:toBeInTheDocument)@babel/preset-env,@babel/preset-react,babel-jest: Jest가 ES Modules 및 JSX 코드를 이해하고 실행할 수 있도록 Babel을 설정합니다.
3. Babel 설정 (.babelrc 또는 babel.config.js)
프로젝트 루트에 babel.config.js 파일을 생성하고 다음과 같이 설정합니다.
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
['@babel/preset-react', {runtime: 'automatic'}],
],
};
runtime: 'automatic'은 React 17부터 JSX 변환이 더 이상 React 객체를 전역 스코프에 요구하지 않도록 해줍니다.
4. Jest 설정 (package.json 또는 jest.config.js)
package.json 파일에 테스트 스크립트와 Jest 설정을 추가합니다.
// package.json
{
"name": "my-react-app",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "jest",
"eject": "react-scripts eject"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
},
// ... other dependencies
}
"test": "jest":npm test명령어를 실행하면 Jest가 동작하도록 합니다."testEnvironment": "jsdom": 브라우저 환경을 모방하여 DOM API를 사용할 수 있게 해줍니다. React 컴포넌트 테스트에 필수적이죠."setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"]: 모든 테스트 파일이 실행되기 전에 특정 파일을 먼저 실행하도록 합니다. 여기에@testing-library/jest-dom을 설정할 거예요."moduleNameMapper": CSS 모듈 등 Jest가 직접 처리할 수 없는 파일을 Mocking 처리할 때 사용합니다.
5. setupTests.js 파일 생성
src 폴더 안에 setupTests.js 파일을 만들고 다음 내용을 추가합니다.
// src/setupTests.js
import '@testing-library/jest-dom';
이 한 줄로 @testing-library/jest-dom이 제공하는 추가 매처(예: .toBeInTheDocument(), .toHaveTextContent() 등)를 모든 테스트 파일에서 사용할 수 있게 됩니다.
이제 모든 설정이 완료되었습니다! npm test를 실행하여 Jest가 잘 동작하는지 확인해 보세요. 처음에는 테스트 파일이 없으니 에러가 나겠지만, Jest가 실행되는 것을 볼 수 있을 거예요.
첫 번째 테스트 작성: 간단한 컴포넌트부터 시작해요!
환경 설정이 끝났으니, 이제 실제로 React 컴포넌트 테스트를 작성해 볼 시간입니다. 가장 기본적인 Button 컴포넌트를 예시로 시작해 볼까요?
예시 컴포넌트: Button.js
src/components/Button.js 파일을 만들고 다음과 같이 작성합니다.
// src/components/Button.js
import React from 'react';
function Button({ onClick, children }) {
return (
<button onClick={onClick}>
{children}
</button>
);
}
export default Button;
테스트 파일 작성: Button.test.js
이제 이 Button 컴포넌트를 테스트할 파일을 src/components/Button.test.js로 생성합니다. 테스트 파일은 보통 테스트할 컴포넌트와 같은 디렉토리에 .test.js 또는 .spec.js 확장자를 붙여서 만듭니다.
// src/components/Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
// describe 블록으로 관련 테스트들을 묶어줍니다.
describe('Button 컴포넌트', () => {
// 첫 번째 테스트: 버튼이 화면에 잘 렌더링되는지 확인
test('버튼 텍스트가 올바르게 렌더링되어야 합니다.', () => {
// 1. 컴포넌트 렌더링
// render 함수는 컴포넌트를 가상 DOM에 렌더링하고, 다양한 쿼리 유틸리티를 반환합니다.
render(<Button>클릭하세요</Button>);
// 2. DOM 요소 쿼리 (찾기)
// screen.getByText는 화면에서 특정 텍스트를 가진 요소를 찾습니다.
// 만약 요소를 찾지 못하면 에러를 발생시킵니다.
const buttonElement = screen.getByText(/클릭하세요/i); // 대소문자 구분 없이 '클릭하세요' 텍스트 찾기
// 3. 예상 결과 검증 (assertion)
// expect는 특정 값이나 객체가 예상하는 상태와 일치하는지 검증합니다.
// .toBeInTheDocument()는 @testing-library/jest-dom에서 제공하는 매처로, 요소가 문서에 있는지 확인합니다.
expect(buttonElement).toBeInTheDocument();
});
// 두 번째 테스트: 버튼 클릭 시 onClick 함수가 호출되는지 확인
test('버튼 클릭 시 onClick 핸들러가 호출되어야 합니다.', () => {
// Jest의 Mock 함수를 생성합니다.
// 이 함수는 호출 여부, 호출 횟수, 전달된 인자 등을 기록합니다.
const handleClick = jest.fn();
// 1. 컴포넌트 렌더링
render(<Button onClick={handleClick}>클릭하세요</Button>);
// 2. DOM 요소 쿼리
const buttonElement = screen.getByText(/클릭하세요/i);
// 3. 사용자 이벤트 시뮬레이션
// fireEvent.click은 특정 요소에 클릭 이벤트를 발생시킵니다.
fireEvent.click(buttonElement);
// 4. 예상 결과 검증
// Mock 함수가 한 번 호출되었는지 확인합니다.
expect(handleClick).toHaveBeenCalledTimes(1);
});
// 세 번째 테스트: 버튼이 'button' 역할을 가지고 있는지 확인
test('버튼은 올바른 ARIA role을 가지고 있어야 합니다.', () => {
render(<Button>Submit</Button>);
// getByRole은 접근성을 고려한 쿼리 방법입니다.
// 'button' 역할을 가진 요소를 찾습니다.
const buttonElement = screen.getByRole('button', { name: /submit/i });
expect(buttonElement).toBeInTheDocument();
});
});
코드를 작성한 후, 터미널에서 npm test를 실행해 보세요. 모든 테스트가 성공적으로 통과하는 것을 확인할 수 있을 거예요!
RTL의 쿼리 우선순위 팁
RTL은 요소를 쿼리하는 다양한 방법을 제공하는데, 그중에서도 사용자 관점에서 가장 의미 있는 쿼리부터 사용하는 것을 권장합니다.
getByRole: 가장 추천하는 방법입니다. 사용자가 스크린 리더 등으로 웹을 탐색할 때 가장 먼저 접하는 정보인 ARIA role을 기반으로 요소를 찾죠. (예:button,textbox,link등)getByLabelText: 폼 요소의 레이블 텍스트를 통해 요소를 찾습니다.getByPlaceholderText:input이나textarea의 플레이스홀더 텍스트를 통해 찾습니다.getByText: 일반 텍스트 콘텐츠를 통해 요소를 찾습니다.getByDisplayValue: 현재 입력 필드의 값을 통해 요소를 찾습니다.getByAltText: 이미지의alt속성을 통해 찾습니다.getByTitle:title속성을 통해 찾습니다.getByTestId: 마지막 수단입니다.data-testid속성을 사용하여 요소를 찾습니다. 이 방법은 컴포넌트의 구현 디테일에 의존하기 때문에, 다른 쿼리로 찾을 수 없을 때만 사용하는 것이 좋습니다.
항상 getByRole을 먼저 고려하고, 적절한 name 옵션과 함께 사용하는 습관을 들이는 것이 좋습니다.
비동기 처리와 Mocking: 네트워크 요청도 문제없어요!
실제 애플리케이션에서는 네트워크 요청과 같은 비동기 작업이 빈번하게 발생하죠. React 컴포넌트가 API를 호출하여 데이터를 가져오고 렌더링하는 경우를 테스트하는 방법을 알아볼게요. 이때 Mocking 기법이 중요하게 사용됩니다.
예시 컴포넌트: UserList.js (API 호출)
사용자 목록을 API에서 가져와서 보여주는 간단한 컴포넌트를 만들어 봅시다.
// src/components/UserList.js
import React, { useState, useEffect } from 'react';
import axios from 'axios'; // axios를 사용한다고 가정합니다.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await axios.get('https://jsonplaceholder.typicode.com/users');
setUsers(response.data);
} catch (err) {
setError('사용자 정보를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) {
return <div>사용자 정보를 불러오는 중입니다...</div>;
}
if (error) {
return <div style={{ color: 'red' }}>{error}</div>;
}
return (
<div>
<h2>사용자 목록</h2>
<ul>
{users.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
</div>
);
}
export default UserList;
이 컴포넌트는 axios를 사용하여 외부 API를 호출하고 있습니다. 테스트 환경에서는 실제 네트워크 요청을 보내는 것은 비효율적이고, 테스트의 일관성을 해칠 수 있어요. 이때 Mocking을 사용합니다.
테스트 파일 작성: UserList.test.js
src/components/UserList.test.js 파일을 만듭니다.
// src/components/UserList.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import UserList from './UserList';
// axios 모듈을 Mocking합니다.
// 이렇게 하면 실제 axios가 아닌 Mocking된 axios가 사용됩니다.
jest.mock('axios');
describe('UserList 컴포넌트', () => {
const mockUsers = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
// 테스트마다 Mocking된 axios의 동작을 재설정합니다.
beforeEach(() => {
// get 메서드가 호출될 때, 특정 값을 반환하도록 설정합니다.
axios.get.mockResolvedValue({ data: mockUsers });
});
// 모든 테스트가 끝난 후 Mocking 상태를 초기화합니다.
afterEach(() => {
jest.clearAllMocks();
});
test('API 호출 후 사용자 목록을 올바르게 렌더링해야 합니다.', async () => {
render(<UserList />);
// 로딩 상태 메시지를 확인합니다.
expect(screen.getByText(/사용자 정보를 불러오는 중입니다.../i)).toBeInTheDocument();
// 비동기 작업이 완료될 때까지 기다립니다.
// findByText는 요소가 비동기적으로 나타날 때까지 기다려줍니다.
await waitFor(() => {
// 이제 로딩 메시지는 사라지고, 사용자 목록이 나타나야 합니다.
expect(screen.queryByText(/사용자 정보를 불러오는 중입니다.../i)).not.toBeInTheDocument();
expect(screen.getByText('사용자 목록')).toBeInTheDocument();
expect(screen.getByText('Alice (alice@example.com)')).toBeInTheDocument();
expect(screen.getByText('Bob (bob@example.com)')).toBeInTheDocument();
});
// axios.get이 올바른 URL로 호출되었는지 확인합니다.
expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledWith('https://jsonplaceholder.typicode.com/users');
});
test('API 호출 실패 시 에러 메시지를 표시해야 합니다.', async () => {
// API 호출이 실패하도록 Mocking합니다.
axios.get.mockRejectedValue(new Error('네트워크 에러'));
render(<UserList />);
// 로딩 메시지를 확인합니다.
expect(screen.getByText(/사용자 정보를 불러오는 중입니다.../i)).toBeInTheDocument();
await waitFor(() => {
// 에러 메시지가 화면에 나타나는지 확인합니다.
expect(screen.getByText('사용자 정보를 불러오는데 실패했습니다.')).toBeInTheDocument();
expect(screen.queryByText(/사용자 정보를 불러오는 중입니다.../i)).not.toBeInTheDocument();
});
});
});
Mocking 심화: jest.mock과 jest.fn()
jest.mock('module-name'): 특정 모듈 전체를 Mocking할 때 사용합니다. 위 예시에서는axios모듈을 Mocking했죠. 이렇게 하면 해당 모듈의 모든 함수가 자동으로 Mock 함수로 대체됩니다.mockResolvedValue(value)/mockRejectedValue(error): 비동기 함수(Promise를 반환하는 함수)가 성공적으로 해결되거나(resolve) 실패할 때(reject) 반환할 값을 지정합니다.jest.fn(): 특정 함수만 Mocking할 때 사용합니다. 예를 들어, 컴포넌트의 props로 전달되는 콜백 함수를 테스트할 때 유용하죠. (위Button컴포넌트 예시의handleClick처럼요)
비동기 쿼리: findBy*와 waitFor
findBy*(예:findByText,findByRole): 요소가 비동기적으로 나타날 때까지 기다리는 쿼리입니다. 기본적으로 1000ms까지 기다리며, 타임아웃 전에 요소가 나타나면 Promise를 resolve하고, 그렇지 않으면 reject합니다. 주로 API 호출 후 데이터 렌더링을 기다릴 때 사용합니다.waitFor: 특정 콜백 함수가 Promise를 reject하지 않을 때까지 기다리게 해주는 유틸리티입니다.findBy*쿼리만으로 부족하거나, 여러 조건이 충족될 때까지 기다려야 할 때 유용합니다. (예: 로딩 스피너가 사라지고, 특정 텍스트가 나타나는 등)
비동기 테스트는 처음에는 복잡하게 느껴질 수 있지만, jest.mock과 findBy*, waitFor를 잘 활용하면 안정적으로 테스트를 작성할 수 있습니다.
Image by Alexandra_Koch on Pixabay
상태 관리와 Context API 컴포넌트 테스트하기
React 애플리케이션에서는 Context API나 Redux, Zustand 같은 상태 관리 라이브러리를 많이 사용하죠. 이러한 전역 상태에 의존하는 컴포넌트를 테스트할 때는 어떻게 해야 할까요? 핵심은 테스트 환경에서도 해당 컴포넌트가 필요로 하는 Context를 제공해주는 것입니다.
예시 컴포넌트: ThemeToggle.js (Context API 사용)
간단한 테마 토글 기능을 Context API를 사용해 구현해 볼게요.
// src/contexts/ThemeContext.js
import React, { createContext, useState, useContext } from 'react';
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light'); // 기본 테마는 'light'
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => useContext(ThemeContext);
// src/components/ThemeToggle.js
import React from 'react';
import { useTheme } from '../contexts/ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<p>현재 테마: <span>{theme}</span></p>
<button onClick={toggleTheme}>테마 전환</button>
</div>
);
}
export default ThemeToggle;
ThemeToggle 컴포넌트는 useTheme 훅을 통해 ThemeContext에 접근하고 있어요. 이 컴포넌트를 단독으로 렌더링하면 Context가 없어서 에러가 발생할 겁니다.
테스트 파일 작성: ThemeToggle.test.js
src/components/ThemeToggle.test.js 파일을 만듭니다.
// src/components/ThemeToggle.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ThemeToggle from './ThemeToggle';
import { ThemeProvider } from '../contexts/ThemeContext';
describe('ThemeToggle 컴포넌트', () => {
// Context를 사용하는 컴포넌트를 테스트할 때는 해당 Context Provider로 감싸줘야 합니다.
// RTL의 render 함수는 `wrapper` 옵션을 제공하여 이를 쉽게 할 수 있도록 해줍니다.
const renderWithThemeProvider = (ui) => {
return render(ui, { wrapper: ThemeProvider });
};
test('초기 테마는 light로 표시되어야 합니다.', () => {
renderWithThemeProvider(<ThemeToggle />);
// '현재 테마: light' 텍스트를 포함하는 요소를 찾습니다.
expect(screen.getByText(/현재 테마: light/i)).toBeInTheDocument();
});
test('테마 전환 버튼 클릭 시 dark 테마로 변경되어야 합니다.', () => {
renderWithThemeProvider(<ThemeToggle />);
// 버튼을 찾고 클릭 이벤트를 발생시킵니다.
const toggleButton = screen.getByRole('button', { name: /테마 전환/i });
fireEvent.click(toggleButton);
// 테마가 'dark'로 변경되었는지 확인합니다.
expect(screen.getByText(/현재 테마: dark/i)).toBeInTheDocument();
expect(screen.queryByText(/현재 테마: light/i)).not.toBeInTheDocument(); // 기존 텍스트는 사라져야 합니다.
});
test('두 번 클릭 시 다시 light 테마로 변경되어야 합니다.', () => {
renderWithThemeProvider(<ThemeToggle />);
const toggleButton = screen.getByRole('button', { name: /테마 전환/i });
// 한 번 클릭: light -> dark
fireEvent.click(toggleButton);
expect(screen.getByText(/현재 테마: dark/i)).toBeInTheDocument();
// 두 번 클릭: dark -> light
fireEvent.click(toggleButton);
expect(screen.getByText(/현재 테마: light/i)).toBeInTheDocument();
expect(screen.queryByText(/현재 테마: dark/i)).not.toBeInTheDocument();
});
});
`wrapper` 옵션 활용
위 예시에서 볼 수 있듯이, render 함수의 wrapper 옵션을 사용하면 테스트할 컴포넌트를 특정 Provider로 쉽게 감쌀 수 있습니다. 이는 Context API뿐만 아니라, Redux의 Provider나 React Router의 BrowserRouter 등 다른 상태 관리/라우팅 라이브러리를 사용할 때도 매우 유용합니다.
만약 여러 Provider를 중첩해야 한다면, 다음과 같이 커스텀 헬퍼 함수를 만들어서 사용할 수도 있습니다.
// 여러 Provider를 중첩하는 Wrapper 예시
const AllProviders = ({ children }) => (
<ThemeContext.Provider value={{ theme: 'light', toggleTheme: jest.fn() }}>
<AnotherContext.Provider value={...}>
{children}
</AnotherContext.Provider>
</ThemeContext.Provider>
);
render( <MyComponent />, { wrapper: AllProviders });
이때 주의할 점은, ThemeProvider 내부의 상태(useState)를 테스트하고 싶을 때는 실제 ThemeProvider를 그대로 사용하는 것이 좋지만, 단순히 Context가 필요한 컴포넌트만 테스트하고 싶고 Context 자체의 로직은 중요하지 않다면, Mocking된 Provider를 만들어 사용하는 것도 좋은 방법입니다.
사용자 정의 훅(Custom Hook) 테스트, 이렇게 하세요!
사용자 정의 훅(Custom Hook)은 React에서 재사용 가능한 로직을 추출하는 강력한 방법이죠. 컴포넌트와 별개로 훅의 로직 자체를 테스트하는 것도 중요합니다. 이를 위해 @testing-library/react-hooks (또는 React Testing Library v13부터는 내장된 renderHook)를 사용할 수 있어요.
예시 훅: useCounter.js
간단한 카운터 훅을 만들어 봅시다.
// src/hooks/useCounter.js
import { useState, useCallback } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(prevCount => prevCount + 1);
}, []);
const decrement = useCallback(() => {
setCount(prevCount => prevCount - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
export default useCounter;
테스트 파일 작성: useCounter.test.js
src/hooks/useCounter.test.js 파일을 만듭니다. 여기서는 @testing-library/react-hooks 라이브러리를 설치했다고 가정합니다. (npm install --save-dev @testing-library/react-hooks)
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks'; // 또는 '@testing-library/react'의 renderHook (v13 이상)
import useCounter from './useCounter';
describe('useCounter 훅', () => {
test('초기 값은 0이어야 합니다.', () => {
// renderHook을 사용하여 훅을 렌더링하고 결과를 반환받습니다.
const { result } = renderHook(() => useCounter());
// result.current는 훅이 반환하는 현재 값을 나타냅니다.
expect(result.current.count).toBe(0);
});
test('초기 값을 설정할 수 있어야 합니다.', () => {
const { result } = renderHook(() => useCounter(100));
expect(result.current.count).toBe(100);
});
test('increment 함수 호출 시 count가 증가해야 합니다.', () => {
const { result } = renderHook(() => useCounter());
// act는 React의 상태 업데이트나 DOM 업데이트를 유발하는 액션들을 래핑합니다.
// 이를 통해 React가 업데이트를 처리할 시간을 주고, 테스트가 예측 가능하게 동작하도록 합니다.
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(3);
});
test('decrement 함수 호출 시 count가 감소해야 합니다.', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('reset 함수 호출 시 초기 값으로 재설정되어야 합니다.', () => {
const { result } = renderHook(() => useCounter(50));
act(() => {
result.current.increment(); // 51
result.current.increment(); // 52
result.current.reset();
});
expect(result.current.count).toBe(50);
act(() => {
result.current.decrement(); // 49
result.current.reset();
});
expect(result.current.count).toBe(50);
});
});
`renderHook`과 `act`의 중요성
renderHook: React 컴포넌트 내부에서 훅을 렌더링하고, 훅의 반환 값과 기타 유틸리티를 제공합니다. 컴포넌트 없이 훅 자체의 로직만 격리하여 테스트할 수 있게 해줍니다.result.current:renderHook이 반환하는 객체 중result는 훅의 현재 반환 값을 담고 있습니다.result.current를 통해 훅의 상태나 함수에 접근할 수 있습니다.act: React 테스트 유틸리티에서 제공하는 함수로, 상태 업데이트나 DOM 업데이트를 유발하는 모든 액션(예:setState, 사용자 이벤트)을act로 감싸야 합니다. 이렇게 해야 React가 해당 업데이트를 "처리"할 시간을 얻고, 테스트가 실제 React 환경과 유사하게 동작하여 예측 가능한 결과를 얻을 수 있습니다.act가 없으면 경고 메시지가 나타날 수 있습니다.
Custom Hook 테스트는 컴포넌트와 로직을 분리하여 테스트할 수 있다는 점에서 매우 효과적이며, 훅의 재사용성과 견고성을 높이는 데 기여합니다.
Image by lukasmilan on Pixabay
테스트 코드 작성 팁과 모범 사례
지금까지 Jest와 React Testing Library를 활용한 다양한 React 컴포넌트 테스트 방법을 알아보았어요. 이제 효과적인 테스트 코드를 작성하기 위한 몇 가지 팁과 모범 사례를 공유해 드릴게요.
1. "사용자 관점"에서 테스트하기 (RTL의 핵심 철학)
React Testing Library의 가장 중요한 철학은 "사용자가 컴포넌트와 상호작용하는 방식과 동일하게 테스트하라"는 것입니다. 이는 컴포넌트의 내부 구현(state, props의 이름, 컴포넌트 계층 구조 등)에 의존하기보다, 사용자가 화면에서 보고 상호작용할 수 있는 요소를 기반으로 테스트를 작성하라는 의미입니다.
- 나쁜 예:
getByTestId('my-internal-state-display')또는wrapper.find('MyChildComponent').props().onClick() - 좋은 예:
getByRole('button', { name: 'Submit' })또는fireEvent.click(screen.getByText('로그인'))
이 원칙을 따르면, 컴포넌트의 내부 로직이 변경되더라도 사용자 경험이 동일하다면 테스트는 깨지지 않습니다. 이는 리팩토링의 자유도를 높여주죠.
2. 테스트의 범위 설정: 유닛, 통합, E2E
테스트는 크게 세 가지 레벨로 나눌 수 있습니다.
- 유닛 테스트 (Unit Test): 가장 작은 단위의 코드(함수, 컴포넌트의 특정 로직)를 개별적으로 테스트합니다. Jest와 RTL은 유닛 테스트에 매우 적합합니다.
- 통합 테스트 (Integration Test): 여러 유닛이 함께 동작하는 방식을 테스트합니다. 예를 들어, 부모 컴포넌트와 자식 컴포넌트가 상호작용하는 방식, 여러 훅이 결합된 로직 등을 테스트할 수 있습니다. RTL은 컴포넌트를 렌더링하고 상호작용하는 방식으로 통합 테스트를 작성하기에 좋습니다.
- E2E 테스트 (End-to-End Test): 실제 사용자가 애플리케이션을 사용하는 전체 흐름을 테스트합니다 (브라우저에서 로그인, 상품 구매 등). Cypress나 Playwright 같은 도구가 주로 사용됩니다. Jest와 RTL은 주로 유닛 및 통합 테스트에 집중하며, E2E 테스트는 별도의 도구를 사용하는 것이 일반적입니다.
대부분의 프론트엔드 테스트는 유닛 및 통합 테스트에 집중하는 것이 효율적입니다. 실제 프로젝트에서는 통합 테스트의 비중을 높여, 컴포넌트 간의 상호작용을 검증하는 것이 중요하다고들 말하죠.
3. Mocking은 신중하게, 하지만 필요할 땐 과감하게
Mocking은 외부 의존성을 제거하여 테스트를 격리하고 빠르게 실행하는 데 필수적입니다. 하지만 너무 많은 Mocking은 테스트의 신뢰성을 떨어뜨릴 수 있어요. 실제 코드가 변경되었을 때 Mocking된 부분이 업데이트되지 않아 잘못된 확신을 줄 수도 있거든요.
- 언제 Mocking해야 할까요? 외부 API 호출, 복잡한 라이브러리 (예: 지도 라이브러리), 시간 관련 함수 (
setTimeout,Date) 등 테스트 환경에서 실제 실행하기 어렵거나, 테스트의 비결정성을 유발하는 경우. - 어떻게 Mocking할까요?
jest.mock()으로 모듈을 Mocking하거나,jest.fn()으로 특정 함수를 Mocking하고mockResolvedValue등으로 반환 값을 조작합니다.
4. 접근성을 고려한 쿼리 사용
앞서 설명했듯이, getByRole과 같은 접근성 관련 쿼리를 우선적으로 사용하는 것이 좋습니다. 이는 테스트의 견고성을 높여줄 뿐만 아니라, 자연스럽게 컴포넌트의 접근성(Accessibility)을 개선하도록 유도합니다. 예를 들어, 버튼에 텍스트가 없다면 name 옵션으로 찾을 수 없으니, aria-label 등을 추가하게 되겠죠.
5. 테스트 커버리지 (Test Coverage) 활용
테스트 커버리지는 작성된 테스트 코드가 전체 코드 중 얼마나 많은 부분을 실행했는지를 나타내는 지표입니다. Jest는 이 기능을 내장하고 있어요. jest --coverage 명령어를 실행하면, 테스트가 실행된 코드 라인, 함수, 브랜치 등의 비율을 상세하게 확인할 수 있습니다.
npm test -- --coverage
하지만 커버리지 숫자에만 맹목적으로 집착하는 것은 좋지 않습니다. 100% 커버리지가 항상 완벽한 테스트를 의미하는 것은 아니거든요. 중요한 것은 의미 있는 시나리오를 얼마나 잘 테스트했는지입니다. 커버리지는 테스트되지 않은 영역을 찾아내고, 잠재적인 위험을 식별하는 데 유용한 도구로 활용하는 것이 바람직합니다.
6. 테스트 클린업 (Cleanup)
각 테스트는 독립적으로 실행되어야 하며, 이전 테스트의 결과가 다음 테스트에 영향을 주지 않아야 합니다. RTL의 render 함수는 자동으로 클린업을 처리해주지만, 만약 jsdom 환경 밖에서 DOM을 직접 조작하거나, 전역적으로 Mocking된 요소가 있다면 cleanup 함수를 명시적으로 호출하거나 afterEach 훅을 사용하여 상태를 초기화하는 것이 좋습니다.
// 예시: Mocking 초기화
afterEach(() => {
jest.clearAllMocks(); // 모든 Mock 함수의 호출 기록과 구현을 초기화
jest.restoreAllMocks(); // jest.spyOn으로 생성된 Mock을 원래 구현으로 복원
});
이러한 팁과 모범 사례들을 적용하여 테스트 코드를 작성한다면, 더욱 견고하고 유지보수하기 쉬운 React 애플리케이션을 만들 수 있을 거예요!
마무리하며: 견고한 UI의 시작, 테스트
지금까지 Jest와 React Testing Library를 활용하여 React 컴포넌트를 테스트하는 방법을 상세하게 알아보았습니다. 기본적인 컴포넌트부터, 비동기 API 호출, Context API를 사용하는 컴포넌트, 그리고 Custom Hook까지, 다양한 상황에서 어떻게 테스트 코드를 작성해야 하는지 실전 예제를 통해 익혀봤죠?
테스트 코드를 작성하는 것은 단순히 버그를 잡는 것을 넘어, 개발 프로세스의 안정성을 높이고, 리팩토링에 대한 두려움을 없애주며, 궁극적으로는 더 높은 품질의 소프트웨어를 만드는 데 기여합니다. 처음에는 테스트 코드 작성에 시간이 더 소요된다고 느낄 수 있지만, 장기적으로는 개발 시간을 단축하고 유지보수 비용을 절감하는 효과를 가져다줄 거예요.
특히 React Testing Library의 '사용자 관점' 테스트 철학은, 우리가 만드는 UI가 실제 사용자에게 어떻게 보여지고 동작할지에 대한 깊은 고민을 유도합니다. 단순히 코드가 동작하는 것을 넘어, 사용자가 만족할 만한 경험을 제공하는 UI를 만드는 데 테스트가 중요한 가이드라인이 될 수 있다는 거죠.
이제 여러분도 Jest와 React Testing Library를 활용하여 견고하고 신뢰할 수 있는 React 컴포넌트들을 만들어낼 준비가 되셨으리라 믿습니다. 꾸준히 테스트 코드를 작성하는 습관을 들이고, 이 과정에서 얻는 피드백을 통해 더 나은 개발자로 성장하시기를 응원합니다!
혹시 궁금한 점이 있으시거나, 자신만의 React 컴포넌트 테스트 노하우가 있다면 댓글로 자유롭게 공유해주세요! 여러분의 경험과 지식이 다른 개발자들에게 큰 도움이 될 거예요. 😊