React 테스팅 기초
5. React 테스팅 기초
React 애플리케이션의 품질을 보장하기 위해서는 체계적인 테스팅이 필요합니다. 이 장에서는 React 테스팅의 기본 개념과 도구들을 학습해보겠습니다.
테스팅이 중요한 이유
테스팅의 장점
// 테스트가 없는 코드의 문제점
function calculateTotal(items, taxRate = 0.1) {
// 버그: items가 배열이 아닐 경우 오류 발생
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 + taxRate);
}
// 사용 시 런타임에서만 오류 발견
// calculateTotal(null); // TypeError 발생
// 테스트를 통해 미리 발견할 수 있는 문제들
function calculateTotal(items, taxRate = 0.1) {
if (!Array.isArray(items)) {
throw new Error('items는 배열이어야 합니다');
}
const subtotal = items.reduce((sum, item) => {
if (typeof item.price !== 'number') {
throw new Error('각 아이템은 숫자 타입의 price 속성을 가져야 합니다');
}
return sum + item.price;
}, 0);
return subtotal * (1 + taxRate);
}
// 테스트 예제
describe('calculateTotal', () => {
test('올바른 총액을 계산해야 함', () => {
const items = [{ price: 100 }, { price: 200 }];
expect(calculateTotal(items, 0.1)).toBe(330);
});
test('빈 배열일 때 0을 반환해야 함', () => {
expect(calculateTotal([], 0.1)).toBe(0);
});
test('items가 배열이 아닐 때 오류를 발생시켜야 함', () => {
expect(() => calculateTotal(null)).toThrow('items는 배열이어야 합니다');
});
});
테스팅 도구 소개
주요 테스팅 라이브러리
Vite로 생성한 React 프로젝트에서는 기본적으로 다음 테스팅 도구들을 사용합니다:
# Vite 프로젝트 생성
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install
# 테스팅 라이브러리 설치
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
테스트 설정 파일
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test-setup.js']
}
})
// src/test-setup.js
import '@testing-library/jest-dom'
첫 번째 테스트 작성하기
간단한 컴포넌트 테스트
// src/components/Greeting.js
function Greeting({ name = '방문자' }) {
return (
<div>
<h1>안녕하세요, {name}님!</h1>
<p>오늘도 좋은 하루 되세요.</p>
</div>
);
}
export default Greeting;
// src/components/Greeting.test.js
import { render, screen } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import Greeting from './Greeting';
describe('Greeting 컴포넌트', () => {
test('기본 인사말을 렌더링해야 함', () => {
render(<Greeting />);
// 텍스트로 요소 찾기
expect(screen.getByText('안녕하세요, 방문자님!')).toBeInTheDocument();
expect(screen.getByText('오늘도 좋은 하루 되세요.')).toBeInTheDocument();
});
test('전달된 이름으로 인사말을 렌더링해야 함', () => {
render(<Greeting name="김개발" />);
expect(screen.getByText('안녕하세요, 김개발님!')).toBeInTheDocument();
});
test('제목이 올바른 태그로 렌더링되어야 함', () => {
render(<Greeting name="김개발" />);
// role로 요소 찾기
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toHaveTextContent('안녕하세요, 김개발님!');
});
});
다양한 쿼리 방법
Testing Library의 쿼리 우선순위
// src/components/LoginForm.js
import { useState } from 'react';
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
await onSubmit({ email, password });
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} aria-label="로그인 폼">
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
disabled={isSubmitting}
aria-label={isSubmitting ? '로그인 중' : '로그인'}
>
{isSubmitting ? '로그인 중...' : '로그인'}
</button>
</form>
);
}
export default LoginForm;
// src/components/LoginForm.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import LoginForm from './LoginForm';
describe('LoginForm 컴포넌트', () => {
test('폼 요소들이 올바르게 렌더링되어야 함', () => {
const mockSubmit = vi.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// 1. getByRole - 가장 선호되는 방법
expect(screen.getByRole('form')).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '이메일' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
// 2. getByLabelText - 레이블과 연결된 input 찾기
expect(screen.getByLabelText('이메일')).toBeInTheDocument();
expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
// 3. getByDisplayValue - 현재 값으로 찾기
expect(screen.getByDisplayValue('')).toBeInTheDocument();
});
test('사용자 입력을 처리해야 함', async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn();
render(<LoginForm onSubmit={mockSubmit} />);
const emailInput = screen.getByLabelText('이메일');
const passwordInput = screen.getByLabelText('비밀번호');
// 사용자 입력 시뮬레이션
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
expect(emailInput).toHaveValue('test@example.com');
expect(passwordInput).toHaveValue('password123');
});
test('폼 제출이 올바르게 동작해야 함', async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn().mockResolvedValue();
render(<LoginForm onSubmit={mockSubmit} />);
// 입력 채우기
await user.type(screen.getByLabelText('이메일'), 'test@example.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');
// 제출 버튼 클릭
await user.click(screen.getByRole('button', { name: '로그인' }));
// 제출 함수가 올바른 데이터로 호출되었는지 확인
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
test('제출 중에는 버튼이 비활성화되어야 함', async () => {
const user = userEvent.setup();
const mockSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)));
render(<LoginForm onSubmit={mockSubmit} />);
// 입력 채우기
await user.type(screen.getByLabelText('이메일'), 'test@example.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');
const submitButton = screen.getByRole('button');
await user.click(submitButton);
// 제출 중 상태 확인
expect(submitButton).toBeDisabled();
expect(screen.getByText('로그인 중...')).toBeInTheDocument();
});
});
비동기 작업 테스트
API 호출이 있는 컴포넌트 테스트
// src/components/UserProfile.js
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!userId) return;
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
// 실제로는 API 호출
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('사용자를 찾을 수 없습니다');
}
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) {
return <div>사용자 정보를 불러오는 중...</div>;
}
if (error) {
return <div role="alert">오류: {error}</div>;
}
if (!user) {
return <div>사용자 정보가 없습니다.</div>;
}
return (
<div>
<h2>{user.name}의 프로필</h2>
<p>이메일: {user.email}</p>
<p>가입일: {user.joinDate}</p>
</div>
);
}
export default UserProfile;
// src/components/UserProfile.test.js
import { render, screen, waitFor } from '@testing-library/react';
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import UserProfile from './UserProfile';
// fetch API 모킹
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('UserProfile 컴포넌트', () => {
beforeEach(() => {
mockFetch.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('사용자 정보를 성공적으로 로드해야 함', async () => {
const mockUser = {
id: 1,
name: '김개발',
email: 'kim@example.com',
joinDate: '2024-01-15'
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser)
});
render(<UserProfile userId={1} />);
// 로딩 상태 확인
expect(screen.getByText('사용자 정보를 불러오는 중...')).toBeInTheDocument();
// 사용자 정보가 로드될 때까지 대기
await waitFor(() => {
expect(screen.getByText('김개발의 프로필')).toBeInTheDocument();
});
expect(screen.getByText('이메일: kim@example.com')).toBeInTheDocument();
expect(screen.getByText('가입일: 2024-01-15')).toBeInTheDocument();
// API가 올바른 URL로 호출되었는지 확인
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
test('API 에러를 올바르게 처리해야 함', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404
});
render(<UserProfile userId={999} />);
// 에러 메시지가 표시될 때까지 대기
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('오류: 사용자를 찾을 수 없습니다');
});
});
test('네트워크 에러를 올바르게 처리해야 함', async () => {
mockFetch.mockRejectedValueOnce(new Error('네트워크 오류'));
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('오류: 네트워크 오류');
});
});
test('userId가 없을 때 API를 호출하지 않아야 함', () => {
render(<UserProfile />);
expect(mockFetch).not.toHaveBeenCalled();
expect(screen.getByText('사용자 정보를 불러오는 중...')).toBeInTheDocument();
});
});
상태 변경 테스트
카운터 컴포넌트 테스트
// src/components/Counter.js
import { useState } from 'react';
function Counter({ initialValue = 0, step = 1 }) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(prev => prev + step);
const decrement = () => setCount(prev => prev - step);
const reset = () => setCount(initialValue);
return (
<div>
<h2>카운터: {count}</h2>
<button onClick={increment}>+{step}</button>
<button onClick={decrement}>-{step}</button>
<button onClick={reset}>리셋</button>
</div>
);
}
export default Counter;
// src/components/Counter.test.js
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect } from 'vitest';
import Counter from './Counter';
describe('Counter 컴포넌트', () => {
test('초기값으로 렌더링되어야 함', () => {
render(<Counter initialValue={5} />);
expect(screen.getByText('카운터: 5')).toBeInTheDocument();
});
test('기본값 0으로 렌더링되어야 함', () => {
render(<Counter />);
expect(screen.getByText('카운터: 0')).toBeInTheDocument();
});
test('증가 버튼이 동작해야 함', async () => {
const user = userEvent.setup();
render(<Counter />);
const incrementButton = screen.getByText('+1');
await user.click(incrementButton);
expect(screen.getByText('카운터: 1')).toBeInTheDocument();
await user.click(incrementButton);
expect(screen.getByText('카운터: 2')).toBeInTheDocument();
});
test('감소 버튼이 동작해야 함', async () => {
const user = userEvent.setup();
render(<Counter initialValue={10} />);
const decrementButton = screen.getByText('-1');
await user.click(decrementButton);
expect(screen.getByText('카운터: 9')).toBeInTheDocument();
});
test('리셋 버튼이 초기값으로 되돌려야 함', async () => {
const user = userEvent.setup();
render(<Counter initialValue={5} />);
// 카운터 변경
await user.click(screen.getByText('+1'));
expect(screen.getByText('카운터: 6')).toBeInTheDocument();
// 리셋
await user.click(screen.getByText('리셋'));
expect(screen.getByText('카운터: 5')).toBeInTheDocument();
});
test('커스텀 step으로 동작해야 함', async () => {
const user = userEvent.setup();
render(<Counter step={5} />);
await user.click(screen.getByText('+5'));
expect(screen.getByText('카운터: 5')).toBeInTheDocument();
await user.click(screen.getByText('-5'));
expect(screen.getByText('카운터: 0')).toBeInTheDocument();
});
});
테스트 실행과 커버리지
package.json 스크립트 설정
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
테스트 커버리지 확인
# 단일 실행으로 모든 테스트 실행
npm run test:run
# 커버리지 리포트와 함께 실행
npm run test:coverage
# 특정 파일 테스트
npm test -- Greeting.test.js
# watch 모드로 실행 (개발 중)
npm test
테스트 작성 모범 사례
AAA 패턴 (Arrange, Act, Assert)
// src/utils/formatCurrency.test.js
import { describe, test, expect } from 'vitest';
function formatCurrency(amount, currency = 'KRW') {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: currency
}).format(amount);
}
describe('formatCurrency 함수', () => {
test('한국 원화로 금액을 포맷해야 함', () => {
// Arrange (준비)
const amount = 1500000;
// Act (실행)
const result = formatCurrency(amount);
// Assert (검증)
expect(result).toBe('₩1,500,000');
});
test('다른 통화로 금액을 포맷해야 함', () => {
// Arrange
const amount = 100;
const currency = 'USD';
// Act
const result = formatCurrency(amount, currency);
// Assert
expect(result).toBe('US$100.00');
});
test('0원도 올바르게 포맷해야 함', () => {
// Arrange
const amount = 0;
// Act
const result = formatCurrency(amount);
// Assert
expect(result).toBe('₩0');
});
});
테스트 이름 작성 규칙
describe('TodoList 컴포넌트', () => {
// ✅ 좋은 테스트 이름
test('완료된 할 일은 체크표시와 취소선이 표시되어야 함', () => {});
test('빈 목록일 때 안내 메시지가 표시되어야 함', () => {});
test('새 할 일 추가 시 목록 끝에 추가되어야 함', () => {});
// ❌ 나쁜 테스트 이름
test('렌더링', () => {});
test('클릭', () => {});
test('작동함', () => {});
});
정리
React 테스팅의 기초 개념을 학습했습니다:
- 테스팅의 중요성: 버그 예방, 리팩토링 안정성, 문서화 효과
- 테스팅 도구: Vitest, Testing Library, jsdom 설정
- 기본 테스트 작성: 컴포넌트 렌더링과 상호작용 테스트
- 쿼리 방법: getByRole, getByLabelText 등의 올바른 사용법
- 비동기 테스트: API 호출과 상태 변경 테스트
- 모킹: fetch API와 외부 의존성 모킹
- 모범 사례: AAA 패턴, 명확한 테스트 이름 작성
테스트는 코드의 품질을 보장하고 유지보수성을 높이는 중요한 도구입니다. 처음에는 테스트 작성에 시간이 걸리지만, 장기적으로는 개발 생산성을 크게 향상시킵니다.
다음 장에서는 컴포넌트 테스팅에 대해 더 자세히 알아보겠습니다.