본문으로 건너뛰기
Advertisement

테스팅

좋은 테스트는 코드의 동작을 문서화하고, 리팩토링 시 안전망 역할을 합니다. React Testing Library는 구현이 아닌 사용자 관점의 테스트를 권장합니다.


React Testing Library 철학

React Testing Library(RTL)의 핵심 원칙은 **"구현이 아닌 동작을 테스트한다"**입니다.

// 나쁜 테스트 (구현 세부사항 테스트)
test('state가 업데이트됨', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1); // 내부 상태 검사
});

// 좋은 테스트 (사용자 관점 테스트)
test('카운트 버튼 클릭 시 숫자가 증가함', async () => {
render(<Counter />);
const button = screen.getByRole('button', { name: '증가' });
await userEvent.click(button);
expect(screen.getByText('1')).toBeInTheDocument();
});

// 테스트 원칙:
// 1. DOM 노드를 직접 찾지 말고 role, label, text로 찾기
// 2. 구현체(state, methods)가 아닌 사용자가 보는 것 테스트
// 3. 단위 테스트보다 통합 테스트 선호

render와 screen

import { render, screen } from '@testing-library/react';

function Greeting({ name, isLoggedIn }) {
if (!isLoggedIn) return <div>로그인이 필요합니다</div>;
return (
<div>
<h1>안녕하세요, {name}님!</h1>
<p role="status">로그인됨</p>
</div>
);
}

test('로그인된 사용자에게 인사 메시지 표시', () => {
render(<Greeting name="Alice" isLoggedIn={true} />);

// getBy: 없으면 에러 (테스트 실패)
expect(screen.getByText('안녕하세요, Alice님!')).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice');
expect(screen.getByRole('status')).toBeInTheDocument();
});

test('비로그인 시 로그인 요구 메시지 표시', () => {
render(<Greeting name="Alice" isLoggedIn={false} />);

expect(screen.getByText('로그인이 필요합니다')).toBeInTheDocument();
// queryBy: 없으면 null 반환 (에러 없음)
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});

요소 탐색 우선순위

// RTL 권장 쿼리 우선순위 (접근성 순)

// 1순위: 역할(role) - 가장 권장
screen.getByRole('button', { name: '제출' });
screen.getByRole('textbox', { name: '이메일' });
screen.getByRole('heading', { level: 2 });
screen.getByRole('link', { name: '자세히 보기' });
screen.getByRole('checkbox', { name: '동의' });

// 2순위: 레이블 텍스트
screen.getByLabelText('이메일 주소');

// 3순위: 플레이스홀더
screen.getByPlaceholderText('이메일을 입력하세요');

// 4순위: 텍스트
screen.getByText('저장');
screen.getByText(/저장/i); // 정규식 (대소문자 무관)

// 5순위: 표시 값
screen.getByDisplayValue('Alice');

// 6순위: alt 텍스트 (이미지)
screen.getByAltText('프로필 사진');

// 7순위: 제목 속성
screen.getByTitle('새로고침');

// 8순위: 테스트 ID (최후 수단)
screen.getByTestId('submit-button');

// 여러 결과 쿼리
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(3);

userEvent 기본 패턴

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

function LoginForm({ onSubmit }) {
return (
<form onSubmit={onSubmit}>
<label htmlFor="email">이메일</label>
<input id="email" name="email" type="email" />

<label htmlFor="password">비밀번호</label>
<input id="password" name="password" type="password" />

<button type="submit">로그인</button>
</form>
);
}

describe('LoginForm', () => {
// userEvent는 setup()으로 사용 권장 (v14+)
const user = userEvent.setup();

test('유효한 이메일과 비밀번호로 제출', async () => {
const mockSubmit = jest.fn((e) => e.preventDefault());
render(<LoginForm onSubmit={mockSubmit} />);

// 클릭
await user.click(screen.getByLabelText('이메일'));

// 타이핑
await user.type(screen.getByLabelText('이메일'), 'alice@example.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');

// 제출
await user.click(screen.getByRole('button', { name: '로그인' }));

expect(mockSubmit).toHaveBeenCalledTimes(1);
});

test('입력 지우기', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
const emailInput = screen.getByLabelText('이메일');

await user.type(emailInput, 'test@example.com');
await user.clear(emailInput);

expect(emailInput).toHaveValue('');
});

test('키보드 단축키', async () => {
render(<LoginForm onSubmit={jest.fn()} />);

await user.keyboard('{Tab}'); // Tab 키
await user.keyboard('{Enter}'); // Enter 키
await user.keyboard('[Space]'); // Space 키
await user.keyboard('{ctrl>}a{/ctrl}'); // Ctrl+A
});

test('select 옵션 선택', async () => {
render(
<select aria-label="언어">
<option value="ko">한국어</option>
<option value="en">English</option>
</select>
);

await user.selectOptions(screen.getByRole('combobox'), 'en');
expect(screen.getByDisplayValue('English')).toBeInTheDocument();
});
});

Jest 기본

// describe: 테스트 그룹화
describe('계산기', () => {
// it (= test): 개별 테스트
it('두 수를 더할 수 있다', () => {
expect(add(2, 3)).toBe(5);
});

// 매처(Matchers)
test('다양한 매처 예시', () => {
// 동등성
expect(1 + 1).toBe(2); // 엄격한 동등 (===)
expect({ a: 1 }).toEqual({ a: 1 }); // 깊은 동등

// 진리값
expect(true).toBeTruthy();
expect(null).toBeFalsy();
expect(undefined).toBeUndefined();
expect(null).toBeNull();

// 숫자
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(5).toBeGreaterThan(3);
expect(2).toBeLessThanOrEqual(2);

// 문자열
expect('Hello World').toMatch(/world/i);
expect('Hello').toContain('ell');

// 배열
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);

// DOM (jest-dom)
expect(element).toBeInTheDocument();
expect(input).toHaveValue('Alice');
expect(button).toBeDisabled();
expect(div).toHaveClass('active');
expect(link).toHaveAttribute('href', '/home');
});
});

비동기 테스트

// async/await
test('데이터를 가져온다', async () => {
const data = await fetchData();
expect(data.name).toBe('Alice');
});

// waitFor: 비동기 DOM 업데이트 대기
import { render, screen, waitFor } from '@testing-library/react';

test('로딩 후 데이터 표시', async () => {
render(<AsyncComponent />);

// 로딩 표시 확인
expect(screen.getByText('로딩 중...')).toBeInTheDocument();

// 데이터가 나타날 때까지 대기
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});

// 로딩 사라짐 확인
expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();
});

// findBy: waitFor + getBy 조합 (권장)
test('버튼 클릭 후 결과 표시', async () => {
const user = userEvent.setup();
render(<SearchComponent />);

await user.type(screen.getByRole('searchbox'), 'Alice');
await user.click(screen.getByRole('button', { name: '검색' }));

// findBy는 자동으로 대기
const result = await screen.findByText('Alice (30세)');
expect(result).toBeInTheDocument();
});

Mock 사용

// jest.fn(): 목 함수
const mockFn = jest.fn();
mockFn('hello');
mockFn(42);

expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith(42);

// 반환값 설정
mockFn.mockReturnValue(42);
mockFn.mockReturnValueOnce('첫 번째'); // 한 번만
mockFn.mockResolvedValue({ data: 'success' }); // Promise
mockFn.mockRejectedValueOnce(new Error('실패'));

// jest.spyOn: 기존 메서드 감시
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
// 테스트 후 복원
spy.mockRestore();

// jest.mock: 모듈 전체 모킹
jest.mock('../api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
createUser: jest.fn().mockResolvedValue({ id: 2, name: 'Bob' })
}));

import { fetchUser } from '../api';

test('사용자 데이터 로드', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
expect(fetchUser).toHaveBeenCalledWith(1);
});

MSW (Mock Service Worker)로 API 모킹

MSW는 서비스 워커를 사용해 실제 HTTP 요청을 가로채는 API 모킹 도구입니다. 프로덕션 코드를 변경하지 않고도 테스트에서 실제 네트워크 요청을 모킹할 수 있습니다.

// src/mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
// GET 요청 처리
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
]);
}),

// URL 파라미터
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
const users = { '1': { id: 1, name: 'Alice' }, '2': { id: 2, name: 'Bob' } };
const user = users[id];

if (!user) {
return new HttpResponse(null, { status: 404 });
}

return HttpResponse.json(user);
}),

// POST 요청 처리
http.post('/api/users', async ({ request }) => {
const body = await request.json();

return HttpResponse.json(
{ id: 3, ...body },
{ status: 201 }
);
}),

// 에러 시뮬레이션
http.get('/api/unstable', () => {
return new HttpResponse(null, { status: 500 });
}),
];
// src/mocks/server.js (테스트용)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// setupTests.js (Jest 설정)
import { server } from './mocks/server';
import '@testing-library/jest-dom';

// 각 테스트 전후 서버 설정
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// 테스트에서 MSW 활용
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import UserList from '../components/UserList';

test('사용자 목록 표시', async () => {
render(<UserList />);

// MSW가 자동으로 /api/users 요청 처리
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(2);
expect(screen.getByText('Alice')).toBeInTheDocument();
});

test('에러 상태 처리', async () => {
// 특정 테스트에서만 에러 응답 오버라이드
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);

render(<UserList />);

const error = await screen.findByText('오류가 발생했습니다');
expect(error).toBeInTheDocument();
});

커스텀 훅 테스팅

import { renderHook, act } from '@testing-library/react';

// 테스트할 커스텀 훅
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);

const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);

return { count, increment, decrement, reset };
}

// 커스텀 훅 테스트
describe('useCounter', () => {
test('초기값이 0이다', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});

test('increment가 카운트를 증가시킨다', () => {
const { result } = renderHook(() => useCounter());

act(() => {
result.current.increment();
});

expect(result.current.count).toBe(1);
});

test('초기값을 지정할 수 있다', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});

test('reset이 초기값으로 되돌린다', () => {
const { result } = renderHook(() => useCounter(5));

act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});

expect(result.current.count).toBe(5);
});
});

// API 호출하는 커스텀 훅 테스트 (MSW 활용)
function useUser(id) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
fetch(`/api/users/${id}`)
.then(r => r.json())
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err.message); setLoading(false); });
}, [id]);

return { user, loading, error };
}

test('useUser가 사용자 데이터를 로드한다', async () => {
const { result } = renderHook(() => useUser(1));

// 초기 상태: 로딩 중
expect(result.current.loading).toBe(true);
expect(result.current.user).toBe(null);

// 데이터 로드 완료 대기
await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.user.name).toBe('Alice');
expect(result.current.error).toBe(null);
});

Provider 테스트

// 컨텍스트를 사용하는 컴포넌트 테스트
import { ThemeProvider } from '../contexts/ThemeContext';

// 커스텀 render 래퍼
function renderWithProviders(ui, { theme = 'light', user = null } = {}) {
function Wrapper({ children }) {
return (
<ThemeProvider initialTheme={theme}>
<AuthProvider initialUser={user}>
{children}
</AuthProvider>
</ThemeProvider>
);
}

return render(ui, { wrapper: Wrapper });
}

test('다크 테마에서 버튼 스타일', () => {
renderWithProviders(<ThemedButton />, { theme: 'dark' });
expect(screen.getByRole('button')).toHaveClass('btn-dark');
});

test('로그인된 사용자에게 내 정보 표시', () => {
renderWithProviders(<UserMenu />, {
user: { name: 'Alice', role: 'admin' }
});
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('관리자')).toBeInTheDocument();
});

고수 팁

1. 테스트 파일 구조 규칙

src/
├── components/
│ ├── Button.jsx
│ └── Button.test.jsx ← 같은 위치 권장
├── hooks/
│ ├── useCounter.js
│ └── useCounter.test.js
└── __tests__/ ← 또는 별도 디렉토리
└── integration/
└── checkout.test.jsx

2. 공통 테스트 유틸리티

// test-utils.jsx
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';

export function renderWithAll(ui, options = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});

function AllProviders({ children }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[options.initialPath ?? '/']}>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}

return render(ui, { wrapper: AllProviders, ...options });
}

// 모든 테스트에서 사용
// import { renderWithAll } from '../test-utils';

3. 스냅샷 테스트 주의사항

// 스냅샷 테스트: 변경 감지에 유용하지만 남용 주의
test('버튼 스냅샷', () => {
const { container } = render(<Button variant="primary">제출</Button>);
expect(container).toMatchSnapshot();
// 처음 실행 시 스냅샷 생성, 이후 변경 시 실패
});

// 더 나은 방법: 구체적인 assertions
test('버튼 렌더링', () => {
render(<Button variant="primary">제출</Button>);
const button = screen.getByRole('button', { name: '제출' });
expect(button).toHaveClass('btn-primary');
expect(button).not.toBeDisabled();
});
Advertisement