Testing Library와 Vitest로 사용자 관점 테스트를 작성하고 버그를 추적하기.
테스트와 디버깅 전략
입문편에서 커스텀 훅을 만들고, Context로 상태를 공유하며, 데이터 패칭 패턴을 익혔습니다. 그렇다면 그 코드가 실제로 의도대로 동작하는지 어떻게 확신할 수 있을까요? 리팩터링을 거쳐도 기존 동작이 깨지지 않는다는 보장은 무엇인가요? 이번 레슨은 이미 작성된 컴포넌트와 훅을 어떻게 검증하고 진단하는지에 집중합니다. 구현 세부사항이 아닌 사용자가 보는 동작을 기준으로 테스트를 설계하고, DevTools와 에러 바운더리로 런타임 문제를 추적하는 전략을 다룹니다.
학습 목표
- React Testing Library의 철학을 이해하고, 구현이 아닌 동작 중심 테스트를 작성할 수 있다.
- 컴포넌트·훅·통합 테스트의 계층별 책임 범위를 설정할 수 있다.
- **MSW(Mock Service Worker)**로 비동기 데이터 패칭 흐름을 모킹하고 검증할 수 있다.
- userEvent로 사용자 상호작용을 정밀하게 시뮬레이션하고 접근성 쿼리를 활용할 수 있다.
- React DevTools Profiler로 불필요한 리렌더 원인을 진단하고, 에러 바운더리로 런타임 오류를 추적할 수 있다.
React Testing Library의 철학: 구현이 아닌 동작 테스트
React Testing Library(RTL)의 핵심 원칙은 단 하나입니다. "테스트는 소프트웨어가 사용되는 방식과 유사할수록 더 많은 신뢰를 줍니다." 이는 컴포넌트의 내부 state, 인스턴스 메서드, props 이름이 아니라 사용자가 실제로 보고 조작하는 것을 기준으로 어서션(assertion)을 작성하라는 뜻입니다.
// ❌ 구현 중심 테스트 — 내부 state를 직접 들여다봄
test('토글 state가 true로 변한다', () => {
const { result } = renderHook(() => useState(false));
act(() => result.current[1](true));
expect(result.current[0]).toBe(true); // 이 테스트는 리팩터링에 취약함
});
// ✅ 동작 중심 테스트 — 사용자가 보는 화면을 검증
test('버튼을 클릭하면 메뉴가 열린다', async () => {
render(<DropdownMenu />);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: '메뉴 열기' }));
expect(screen.getByRole('menu')).toBeVisible();
});
구현 중심 테스트의 함정은 리팩터링 내성이 없다는 점입니다. 내부 변수 이름을 바꾸거나 useState를 useReducer로 교체하는 순간 테스트가 깨집니다. 반면 동작 중심 테스트는 외부 인터페이스(DOM, 접근성 트리)만 검증하므로 구현을 자유롭게 변경할 수 있습니다.
💡 TIP
getByRole은 ARIA role을 기반으로 요소를 찾습니다. 스크린리더가 인식하는 것과 동일한 방식으로 요소를 조회하므로, 테스트를 작성하면서 자연스럽게 접근성을 검증하게 됩니다.
테스트 계층과 범위 설정
모든 것을 통합 테스트로 작성하면 느리고, 모든 것을 단위 테스트로 작성하면 실제 통합 지점의 버그를 놓칩니다. 다음과 같이 세 계층을 구분하여 범위를 설계합니다.
| 계층 | 검증 대상 | 도구 | 속도 |
|---|---|---|---|
| 단위(Unit) | 순수 함수, 커스텀 훅 | renderHook, vitest | 빠름 |
| 컴포넌트(Component) | 단일 컴포넌트의 렌더링·상호작용 | render, userEvent | 중간 |
| 통합(Integration) | 여러 컴포넌트·API 연동 흐름 | render + MSW | 느림 |
커스텀 훅 단위 테스트
입문편에서 작성한 useCounter 훅이 있다고 가정합니다. 훅 자체의 로직만 격리해서 검증할 때는 renderHook을 사용합니다.
// hooks/useCounter.ts
export function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
return { count, increment, decrement };
}
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('초기값이 없으면 0에서 시작한다', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increment를 호출하면 count가 1 증가한다', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.increment());
expect(result.current.count).toBe(6);
});
it('decrement를 호출하면 count가 1 감소한다', () => {
const { result } = renderHook(() => useCounter(5));
act(() => result.current.decrement());
expect(result.current.count).toBe(4);
});
});
act()는 상태 업데이트를 동기적으로 플러시합니다. 이벤트 핸들러 대신 훅 함수를 직접 호출할 때는 반드시 act로 감싸야 경고 없이 어서션이 통과합니다.
컴포넌트 테스트 범위 판단
컴포넌트 테스트는 "이 컴포넌트의 props 계약이 올바르게 지켜지는가"를 검증합니다. 자식 컴포넌트의 내부 구현은 신경 쓰지 않고, 렌더링 결과와 이벤트 핸들러 호출 여부에만 집중합니다.
// components/TodoItem.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TodoItem } from './TodoItem';
test('완료된 항목에는 취소선이 표시된다', () => {
render(<TodoItem text="장보기" completed={true} onToggle={() => {}} />);
// ✅ 클래스명이 아니라 접근성 상태로 확인
expect(screen.getByRole('checkbox')).toBeChecked();
});
test('체크박스 클릭 시 onToggle이 호출된다', async () => {
const onToggle = vi.fn();
render(<TodoItem text="장보기" completed={false} onToggle={onToggle} />);
await userEvent.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledOnce();
});
⚠️ 주의 컴포넌트 테스트에서
vi.mock으로 자식 컴포넌트를 너무 많이 목(mock)하면, 실제 통합 지점의 버그를 테스트가 잡아내지 못합니다. 자식 컴포넌트가 단순하다면 실제 컴포넌트를 그대로 사용하세요.
비동기 UI와 MSW를 이용한 데이터 패칭 모킹
API 서버에 실제로 요청을 보내는 테스트는 느리고 불안정합니다. MSW(Mock Service Worker)는 네트워크 레벨에서 요청을 가로채어 핸들러로 응답하므로, 실제 fetch/axios 코드를 그대로 실행하면서도 외부 의존성을 제거할 수 있습니다.
MSW 설정
npm install --save-dev msw
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: '김개발' },
{ id: 2, name: '이테스트' },
]);
}),
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === '999') {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({ id: Number(id), name: '김개발' });
}),
];
// mocks/server.ts (Node 환경 — Vitest용)
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // 각 테스트 후 핸들러 초기화
afterAll(() => server.close());
비동기 렌더링 테스트
// components/UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { UserList } from './UserList';
test('사용자 목록을 로딩 후 화면에 표시한다', async () => {
render(<UserList />);
// 로딩 상태 먼저 확인
expect(screen.getByText('로딩 중...')).toBeInTheDocument();
// 데이터가 나타날 때까지 대기
expect(await screen.findByText('김개발')).toBeInTheDocument();
expect(screen.getByText('이테스트')).toBeInTheDocument();
});
test('API 오류 시 에러 메시지를 표시한다', async () => {
// 이 테스트에서만 핸들러 오버라이드
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByRole('alert')).toHaveTextContent('데이터를 불러올 수 없습니다');
});
findBy* 쿼리는 내부적으로 waitFor를 사용하므로, DOM이 업데이트될 때까지 폴링합니다. getBy*는 동기 조회이므로 비동기 상태에 사용하면 테스트가 즉시 실패합니다.
| 쿼리 prefix | 동작 | 실패 시 |
|---|---|---|
getBy | 동기, 단건 | 즉시 throw |
queryBy | 동기, 단건, 없으면 null | - |
findBy | 비동기(await), 단건 | timeout 후 throw |
getAllBy / queryAllBy / findAllBy | 위와 동일, 복수 반환 | - |
사용자 상호작용 시뮬레이션과 접근성 쿼리
fireEvent는 DOM 이벤트를 직접 발생시키지만, 실제 브라우저에서의 사용자 행동과 차이가 있습니다. @testing-library/user-event의 userEvent는 포커스 이동, 키보드 이벤트 시퀀스, 클립보드 작업 등을 브라우저와 동일하게 시뮬레이션합니다.
// components/SearchForm.test.tsx
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import { SearchForm } from './SearchForm';
test('검색어를 입력하고 제출하면 onSearch가 호출된다', async () => {
const user = userEvent.setup(); // ✅ v14+: setup()으로 인스턴스 생성
const onSearch = vi.fn();
render(<SearchForm onSearch={onSearch} />);
const input = screen.getByRole('textbox', { name: '검색어' });
await user.type(input, 'React 테스트');
await user.keyboard('{Enter}');
expect(onSearch).toHaveBeenCalledWith('React 테스트');
});
test('탭 키로 폼 요소를 순서대로 탐색할 수 있다', async () => {
const user = userEvent.setup();
render(<SearchForm onSearch={() => {}} />);
await user.tab();
expect(screen.getByRole('textbox', { name: '검색어' })).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: '검색' })).toHaveFocus();
});
접근성 쿼리 우선순위
RTL은 쿼리 우선순위를 명시적으로 권장합니다. 아래 순서로 우선 적용하고, 불가능할 때만 다음 단계로 내려갑니다.
1. getByRole — ARIA role + accessible name
2. getByLabelText — <label>과 연결된 입력 요소
3. getByPlaceholderText — placeholder (권장하지 않음, 접근성 취약)
4. getByText — 텍스트 콘텐츠
5. getByDisplayValue — 현재 값 (select, input)
6. getByAltText — img alt
7. getByTitle — title 속성
8. getByTestId — data-testid (최후의 수단)
// ❌ testId에 의존 — 구현 세부사항
screen.getByTestId('submit-btn');
// ✅ 접근성 role 사용 — 사용자와 스크린리더가 인식하는 것과 동일
screen.getByRole('button', { name: '제출' });
⚠️ 주의
data-testid는 제거되거나 이름이 바뀌어도 테스트에는 보이지 않습니다. 접근성 쿼리를 사용하면 테스트가 깨질 때 접근성 문제도 함께 발견할 수 있습니다.
React DevTools와 Profiler로 리렌더 원인 진단
코드 상에서 논리적으로 올바르더라도 불필요한 리렌더가 성능 문제를 일으킬 수 있습니다. React DevTools의 Profiler 탭은 컴포넌트별 렌더링 시간과 원인을 기록합니다.
Profiler 사용법
브라우저 확장의 Profiler 탭에서 "Record" 버튼을 누르고 상호작용 후 정지하면, 각 커밋(commit)에서 어떤 컴포넌트가 렌더링됐는지와 그 이유(props 변경, state 변경, context 변경, 강제 업데이트)를 색상으로 표시합니다.
코드 내에서 성능을 프로파일링하려면 React의 <Profiler> 컴포넌트를 사용합니다.
import { Profiler, ProfilerOnRenderCallback } from 'react';
const onRender: ProfilerOnRenderCallback = (
id, // 컴포넌트 트리 식별자
phase, // "mount" | "update" | "nested-update"
actualDuration, // 실제 렌더링 시간 (ms)
baseDuration, // 메모이제이션 없이 걸릴 예상 시간
startTime,
commitTime,
) => {
if (actualDuration > 16) {
// 16ms 이상이면 60fps를 떨어뜨릴 수 있음
console.warn(`[Profiler] ${id} took ${actualDuration.toFixed(2)}ms (${phase})`);
}
};
function App() {
return (
<Profiler id="ProductList" onRender={onRender}>
<ProductList />
</Profiler>
);
}
불필요한 리렌더 패턴과 진단
DevTools에서 컴포넌트가 "props changed"로 표시되는데 실제 값이 같다면, 참조 동일성 문제일 가능성이 높습니다.
// ❌ 매 렌더마다 새 객체 생성 → 자식이 항상 리렌더
function Parent() {
const [count, setCount] = useState(0);
const config = { theme: 'dark' }; // 매번 새 참조
return <Child config={config} />;
}
// ✅ useMemo로 참조 안정화
function Parent() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ theme: 'dark' }), []);
return <Child config={config} />;
}
DevTools의 "Highlight updates when components render" 옵션을 켜면 리렌더가 발생하는 컴포넌트가 시각적으로 강조 표시되어, 범위를 좁히는 데 유용합니다.
에러 바운더리와 로깅을 통한 런타임 오류 추적
렌더링 도중 발생한 예외는 React가 전체 트리를 언마운트하기 전에 에러 바운더리가 가로챌 수 있습니다. 에러 바운더리는 getDerivedStateFromError와 componentDidCatch를 구현하는 클래스 컴포넌트입니다.
// components/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
fallback: ReactNode;
onError?: (error: Error, info: ErrorInfo) => void;
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Sentry, Datadog 등 외부 모니터링 서비스로 전송
this.props.onError?.(error, info);
console.error('[ErrorBoundary]', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// 사용 예시 — 섹션별로 독립적인 에러 격리
function Dashboard() {
return (
<div>
<ErrorBoundary
fallback={<p>프로필을 불러올 수 없습니다.</p>}
onError={(err) => monitoring.capture(err)}
>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary
fallback={<p>통계 데이터를 불러올 수 없습니다.</p>}
>
<StatsPanel />
</ErrorBoundary>
</div>
);
}
에러 바운더리를 테스트할 때는 console.error를 억제하지 않으면 테스트 출력이 지저분해집니다.
// ErrorBoundary.test.tsx
const ThrowingComponent = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) throw new Error('테스트 에러');
return <p>정상 렌더링</p>;
};
test('에러 발생 시 폴백 UI를 표시한다', () => {
// React 내부 console.error 억제 (테스트 환경에서만)
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<p>오류가 발생했습니다</p>}>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('오류가 발생했습니다')).toBeInTheDocument();
spy.mockRestore();
});
💡 TIP
react-error-boundary라이브러리를 사용하면useErrorBoundary훅,resetKeys자동 복구,onReset콜백 등을 선언적으로 활용할 수 있습니다. 직접 구현 대신 이 라이브러리를 기반으로 확장하는 것을 권장합니다.
CI에서의 테스트 자동화와 커버리지 판단 기준
테스트는 로컬에서만 돌리는 것이 아니라 CI(Continuous Integration) 파이프라인에서 자동으로 실행되어야 합니다. Vitest는 --coverage 플래그와 함께 @vitest/coverage-v8(또는 istanbul)을 사용해 커버리지 리포트를 생성합니다.
# 설치
npm install --save-dev @vitest/coverage-v8
# 커버리지 포함 실행
npx vitest run --coverage
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// ✅ describe/it/expect/vi 등을 import 없이 전역으로 사용하려면 필수
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
exclude: [
'src/mocks/**',
'src/**/*.stories.tsx',
'src/main.tsx',
],
},
},
});
⚠️ 주의 Vitest는 기본적으로
globals가 비활성화되어 있어, 이 설정 없이는describe/it/expect/vi가 정의되지 않아 모든 테스트가ReferenceError로 실패합니다. 위처럼test.globals: true를 켜면 전역 API를 import 없이 사용할 수 있으며, 타입 인식을 위해tsconfig.json의compilerOptions.types에"vitest/globals"를 추가해야 합니다.
// tsconfig.json
{
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
}
}
전역 API를 쓰지 않으려면, 각 테스트 파일 상단에서 import { describe, it, expect, vi } from 'vitest'로 명시적으로 import하는 방식으로 통일할 수도 있습니다. 본 레슨의 예제는 globals: true를 전제로 작성되었습니다.
GitHub Actions 연동 예시
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
커버리지 수치 해석
커버리지 수치는 절대 기준이 아닙니다. 100%를 달성하더라도 잘못된 어서션이면 버그를 잡지 못합니다.
| 상황 | 권장 기준 | 이유 |
|---|---|---|
| 비즈니스 로직(순수 함수) | 90% 이상 | 분기 하나가 결제·보안에 영향 |
| UI 컴포넌트 | 70~80% | 시각적 세부사항은 E2E/스냅샷이 담당 |
| 유틸리티 함수 | 95% 이상 | 재사용성이 높아 회귀 위험 큼 |
| 설정·상수 파일 | 제외 | 테스트 가치 없음 |
⚠️ 주의 커버리지 임계값을 너무 높게 설정하면 팀이 의미 없는 테스트를 작성해 커버리지만 채우는 역효과가 납니다. "어떤 버그를 잡으려는가"를 먼저 정의하고, 그 경로를 검증하는 테스트를 작성하세요.
요약
- RTL의 핵심 철학: 사용자가 보고 조작하는 것을 기준으로 테스트를 작성하면 리팩터링 내성이 생기고 접근성을 자연스럽게 확보할 수 있다.
- 테스트 계층: 단위(훅·순수 함수) → 컴포넌트(렌더링·이벤트) → 통합(API 흐름) 순으로 범위를 설계하고, 각 계층이 중복되지 않도록 책임을 분리한다.
- MSW: 실제 fetch 코드를 그대로 두면서 네트워크 레벨에서 응답을 모킹하므로, 통합 테스트에서 가장 현실적인 시나리오를 검증할 수 있다.
- userEvent:
fireEvent보다 실제 브라우저 동작에 가까운 이벤트 시퀀스를 재현하며, 접근성 쿼리(getByRole,getByLabelText)와 함께 사용하면 품질과 접근성을 동시에 검증할 수 있다. - DevTools Profiler: "props changed"로 표시되는 불필요한 리렌더를 발견하면 참조 동일성 문제를 가장 먼저 확인한다.
- 에러 바운더리: 렌더링 예외를 격리하고 외부 모니터링 서비스와 연결하면 프로덕션 장애의 영향 범위를 최소화할 수 있다.
연습문제
-
아래
LoginForm컴포넌트에 대한 테스트를 작성하세요. 이메일과 비밀번호를 입력하고 제출했을 때onLogin이 올바른 값으로 호출되는지, 그리고 비밀번호 필드가 빈 채로 제출하면 에러 메시지가 표시되는지 검증해야 합니다.// LoginForm.tsx export function LoginForm({ onLogin }: { onLogin: (email: string, password: string) => void }) { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!password) { setError('비밀번호를 입력해주세요'); return; } onLogin(email, password); }; return ( <form onSubmit={handleSubmit}> <label>이메일 <input type="email" value={email} onChange={e => setEmail(e.target.value)} /></label> <label>비밀번호 <input type="password" value={password} onChange={e => setPassword(e.target.value)} /></label> {error && <p role="alert">{error}</p>} <button type="submit">로그인</button> </form> ); }힌트
getByLabelText로 입력 필드를 찾고,userEvent.type으로 값을 입력하세요. -
MSW를 사용하여
/api/posts엔드포인트를 모킹하고,PostList컴포넌트가 데이터 로딩 중에는 스켈레톤(또는 로딩 텍스트)을, 로딩 완료 후에는 포스트 제목 목록을 표시하는지 검증하는 통합 테스트를 작성하세요.힌트
findAllByRole('heading')으로 로딩이 완료된 후의 헤딩 목록을 비동기로 조회하세요. -
useLocalStorage훅을 테스트하세요. 초기값 저장, 값 업데이트, 그리고localStorage가 이미 값을 가지고 있을 때 저장된 값으로 초기화되는 세 가지 케이스를 작성하세요.힌트
localStorage는jsdom환경에서 기본 제공되므로 별도 목 없이clear()로 격리만 하면 됩니다. -
아래
DataWidget컴포넌트에 에러 바운더리를 적용하는 테스트를 작성하세요.DataWidget이 에러를 던질 때 폴백 UI("위젯을 불러올 수 없습니다")가 화면에 나타나야 하고, 에러 바운더리의onError콜백이 호출되어야 합니다.// DataWidget.tsx export function DataWidget({ fail }: { fail: boolean }) { if (fail) throw new Error('위젯 오류'); return <div>정상 위젯</div>; }힌트
vi.spyOn(console, 'error').mockImplementation(() => {})으로 React의 에러 로그를 억제하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“React.js 심화” 강좌에 대한 댓글입니다.