컴포넌트 테스트 실전 — 폼, 모달, 비동기 컴포넌트
단순한 버튼이나 텍스트 표시는 테스트하기 쉽습니다. 하지만 API를 호출하는 폼, Portal로 렌더링되는 모달은 어떻게 테스트할까요?
실제 프로젝트에서 테스트가 어려운 컴포넌트는 대부분 비동기 동작, 포탈, 외부 의존성이 얽혀 있습니다. 이런 "까다로운" 컴포넌트를 테스트하는 실전 패턴을 정리합니다.
폼 테스트
기본 폼 테스트
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email.includes('@')) {
setError('올바른 이메일 형식이 아닙니다');
return;
}
onSubmit({ 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>
);
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
test('유효한 이메일과 비밀번호로 제출하면 onSubmit이 호출된다', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('이메일'), 'test@example.com');
await user.type(screen.getByLabelText('비밀번호'), 'password123');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
test('잘못된 이메일로 제출하면 에러 메시지가 표시된다', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
await user.type(screen.getByLabelText('이메일'), 'invalid-email');
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'올바른 이메일 형식이 아닙니다'
);
});
test('빈 폼은 제출되지 않는다', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.click(screen.getByRole('button', { name: '로그인' }));
expect(handleSubmit).not.toHaveBeenCalled();
});
});
모달 (Portal) 테스트
포탈 기반 모달 컴포넌트
function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content"
role="dialog"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<h2>{title}</h2>
{children}
<button onClick={onClose}>닫기</button>
</div>
</div>,
document.getElementById('modal-root')
);
}
테스트 설정
describe('Modal', () => {
// Portal 대상 노드를 테스트 환경에 추가
beforeEach(() => {
const modalRoot = document.createElement('div');
modalRoot.setAttribute('id', 'modal-root');
document.body.appendChild(modalRoot);
});
afterEach(() => {
const modalRoot = document.getElementById('modal-root');
if (modalRoot) document.body.removeChild(modalRoot);
});
test('isOpen이 true일 때 모달이 표시된다', () => {
render(
<Modal isOpen={true} onClose={jest.fn()} title="확인">
<p>정말 삭제하시겠습니까?</p>
</Modal>
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('정말 삭제하시겠습니까?')).toBeInTheDocument();
});
test('isOpen이 false일 때 모달이 표시되지 않는다', () => {
render(
<Modal isOpen={false} onClose={jest.fn()} title="확인">
<p>내용</p>
</Modal>
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
test('닫기 버튼을 클릭하면 onClose가 호출된다', async () => {
const handleClose = jest.fn();
const user = userEvent.setup();
render(
<Modal isOpen={true} onClose={handleClose} title="확인">
<p>내용</p>
</Modal>
);
await user.click(screen.getByRole('button', { name: '닫기' }));
expect(handleClose).toHaveBeenCalledTimes(1);
});
test('오버레이를 클릭하면 onClose가 호출된다', async () => {
const handleClose = jest.fn();
const user = userEvent.setup();
render(
<Modal isOpen={true} onClose={handleClose} title="확인">
<p>내용</p>
</Modal>
);
// 오버레이 클릭 (모달 콘텐츠 바깥)
await user.click(screen.getByRole('dialog').parentElement);
expect(handleClose).toHaveBeenCalledTimes(1);
});
});
MSW로 API Mocking
MSW 설정
npm install -D msw
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: '홍길동', email: 'hong@example.com' },
{ id: 2, name: '김철수', email: 'kim@example.com' },
]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 3, ...body },
{ status: 201 }
);
}),
http.delete('/api/users/:id', ({ params }) => {
return HttpResponse.json({ success: true });
}),
];
// mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// jest.setup.js 또는 vitest.setup.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
비동기 컴포넌트 테스트
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then(setUsers)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <p>로딩 중...</p>;
if (error) return <p role="alert">에러: {error}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
test('사용자 목록을 로딩하고 표시한다', async () => {
render(<UserList />);
// 로딩 상태 확인
expect(screen.getByText('로딩 중...')).toBeInTheDocument();
// 데이터 로딩 완료 대기
expect(await screen.findByText('홍길동')).toBeInTheDocument();
expect(screen.getByText('김철수')).toBeInTheDocument();
// 로딩이 사라졌는지 확인
expect(screen.queryByText('로딩 중...')).not.toBeInTheDocument();
});
test('API 에러 시 에러 메시지를 표시한다', async () => {
// 이 테스트에서만 에러 응답으로 오버라이드
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
})
);
render(<UserList />);
expect(await screen.findByRole('alert')).toBeInTheDocument();
});
스냅샷 테스트
스냅샷 테스트는 컴포넌트의 렌더링 결과를 파일로 저장하고, 이후 변경을 감지합니다.
test('카드 컴포넌트가 올바르게 렌더링된다', () => {
const { container } = render(
<Card title="제목" description="설명" />
);
expect(container.firstChild).toMatchSnapshot();
});
인라인 스냅샷
test('Badge가 올바르게 렌더링된다', () => {
const { container } = render(<Badge status="active" />);
expect(container.firstChild).toMatchInlineSnapshot(`
<span class="badge badge-active">
active
</span>
`);
});
스냅샷 테스트의 한계
- **의도 파악 어려움 **: 스냅샷이 변경됐을 때 의도적인지 버그인지 구분하기 어렵습니다
- ** 무의식적 업데이트 **:
--updateSnapshot플래그로 무비판적으로 업데이트하기 쉽습니다 - ** 큰 스냅샷 **: 컴포넌트가 크면 스냅샷 파일도 커져 리뷰가 어렵습니다
스냅샷 테스트는 보조적으로만 사용하고, 핵심 동작은 명시적 assertion으로 검증하는 것이 좋습니다.
복합 테스트 시나리오
CRUD 플로우 테스트
test('사용자 추가 → 목록 확인 → 삭제 플로우', async () => {
const user = userEvent.setup();
render(<UserManagement />);
// 초기 목록 로딩 대기
await screen.findByText('홍길동');
// 사용자 추가
await user.click(screen.getByRole('button', { name: '사용자 추가' }));
await user.type(screen.getByLabelText('이름'), '새 사용자');
await user.type(screen.getByLabelText('이메일'), 'new@example.com');
await user.click(screen.getByRole('button', { name: '저장' }));
// 추가된 사용자 확인
expect(await screen.findByText('새 사용자')).toBeInTheDocument();
// 사용자 삭제
const deleteButtons = screen.getAllByRole('button', { name: '삭제' });
await user.click(deleteButtons[deleteButtons.length - 1]);
// 삭제 확인 모달
await user.click(screen.getByRole('button', { name: '확인' }));
// 삭제 완료 확인
await waitFor(() => {
expect(screen.queryByText('새 사용자')).not.toBeInTheDocument();
});
});
테스트 팁
cleanup은 자동으로
React Testing Library는 각 테스트 후 자동으로 cleanup합니다. 수동 호출은 불필요합니다.
debug로 현재 DOM 확인
test('디버깅', () => {
render(<MyComponent />);
screen.debug(); // 현재 DOM을 콘솔에 출력
screen.debug(screen.getByRole('button')); // 특정 요소만 출력
});
logRoles로 접근성 역할 확인
import { logRoles } from '@testing-library/react';
test('역할 확인', () => {
const { container } = render(<MyComponent />);
logRoles(container); // 모든 요소의 ARIA 역할을 출력
});
정리
실전 컴포넌트 테스트의 핵심 패턴입니다.
- ** 폼 테스트 **: userEvent로 입력하고, 검증 에러와 onSubmit 호출을 확인합니다
- ** 모달 테스트 **: Portal 대상 DOM을 beforeEach에서 생성하고 afterEach에서 제거합니다
- **API 테스트 **: MSW로 네트워크 레벨에서 응답을 모킹하여 실제와 가까운 테스트를 합니다
- ** 스냅샷 테스트 **: 보조적으로만 사용하고, 핵심 동작은 명시적 assertion으로 검증합니다
- ** 에러 케이스 **: server.use로 특정 테스트에서만 에러 응답을 오버라이드합니다
주의할 점
스냅샷 테스트에만 의존하면 실질적 버그를 놓침
스냅샷은 "변경이 있었다"만 알려주지, "변경이 올바른가"는 알려주지 않습니다. 핵심 동작(폼 제출, 에러 표시, 비동기 결과)은 명시적 assertion으로 검증하고, 스냅샷은 보조적으로만 사용해야 합니다.
Portal 기반 모달 테스트에서 타겟 DOM을 생성하지 않는 실수
모달이 Portal을 사용하면 document.getElementById('modal-root')가 필요합니다. beforeEach에서 타겟 DOM을 생성하고 afterEach에서 제거해야 합니다.
테스트가 어려운 컴포넌트는 대부분 "너무 많은 것을 하는 컴포넌트"입니다. 테스트하기 어렵다면, 컴포넌트를 더 작은 단위로 분리하는 것을 고려해야 합니다.
댓글 로딩 중...