테스팅 강좌 #1 왜 테스트인가 — 단위/통합/E2E의 역할
리액트 트랙 과 타입스크립트 트랙 을 마치고 자연스럽게 부딪히는 주제 — 테스트 입니다. 도구는 많고(Vitest, Jest, Cypress, Playwright), 패러다임도 갈라져 있습니다(behavior vs implementation). 첫 글에서는 도구가 아니라 그림을 먼저 잡습니다.
이번 시리즈는 테스팅 6편입니다.
- #1 왜 테스트인가 — 단위/통합/E2E의 역할 ← 이번 글
- #2 Vitest 셋업과 첫 단위 테스트
- #3 React Testing Library — 컴포넌트 테스트
- #4 비동기와 네트워크 모킹 — MSW
- #5 사용자 이벤트와 폼 테스트
- #6 Playwright로 E2E와 CI 통합
이번 글은 코드가 거의 없습니다. 다음 글부터의 모든 결정이 이 그림을 바탕으로 합니다.
테스트가 안 되는 진짜 이유 #
“바빠서” 가 가장 흔한 답입니다. 그러나 한 발만 들어가 보면 다른 결이 보입니다.
- 무엇을 테스트해야 할지 모르겠다 — 컴포넌트 하나에 테스트 5개씩 짜야 할 것 같은 압박. 결국 어디서 멈출지 모름.
- 테스트가 깨지는 게 더 무섭다 — 코드를 조금만 바꿔도 테스트 20개가 붉어지는 경험. “이럴 거면 안 짜는 게 낫다”
- CI가 빨갛고 아무도 안 본다 — 한 번 깨지기 시작하면 무뎌짐.
이 셋이 모이면 “테스트는 좋은데 우리 프로젝트에서는 안 맞아” 라는 결론이 됩니다. 사실은 어디에 어떤 테스트를 쓸지의 갈림길에서 한 번 잘못 판단한 결과입니다. 그 갈림길이 이번 글의 주제입니다.
테스트 피라미드 — 가장 오래된 그림 #
먼저 익숙한 그림부터 살펴봅니다.
/\
/ \
/ E2E\ ← 적게, 비싸게, 느리게
/------\
/ 통합 \ ← 적당히
/--------- \
/ 단위 \ ← 많이, 싸게, 빠르게
/--------------\세 층의 의미:
- 단위(unit) — 함수 / 클래스 / 컴포넌트 한 개를 다른 것들과 분리해 테스트. 보통 ms 단위. 수백~수천 개를 들고 다녀도 부담 없음.
- 통합(integration) — 여러 모듈이 함께 도는 영역입니다. DB와 함께 도는 서버 핸들러, 자식 컴포넌트와 부모 컴포넌트의 상호작용 등.
- E2E(end-to-end) — 실제 브라우저 / 실제 서버 / 실제 DB까지 함께. 사용자 시나리오 그대로.
피라미드의 모양이 권하는 건 명확합니다. 단위를 가장 많이, E2E를 가장 적게. 이유:
- 비용 — 단위는 ms, E2E는 초 단위. 100개의 E2E와 100개의 단위 테스트는 시간 격차가 압도적.
- 안정성 — E2E는 네트워크/타이밍/디스플레이 같은 변수에 민감. 같은 테스트가 어떨 땐 통과, 어떨 땐 실패하는 flaky가 심함.
- 디버깅 — E2E가 깨지면 “어디서” 깨졌는지 추적이 어려움.
그러나 — 단위만으로는 안 잡히는 부분이 있다 #
피라미드를 너무 곧이곧대로 받아들이면 함정에 빠집니다. 대표적인 안티패턴:
test('useCounter sets state correctly', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.setCount(5));
expect(result.current.count).toBe(5);
});이 테스트가 잡는 게 무엇일까요? setCount(5)를 부르면 count가 5가 된다 — 즉 React의 useState가 동작한다는 사실. 우리 코드의 문제는 거의 안 잡습니다. 그러면서 useCounter의 내부를 바꾸면 깨져요.
이런 테스트가 쌓이면 — 리팩터링이 두려워지는 상황에 도착합니다. “기능은 같은데 구현만 바꿨어” 인데 테스트가 50개 붉어지면, 다시는 리팩터링을 안 하게 됩니다.
해결의 방향은 두 가지.
- behavior를 테스트하라 — 사용자/호출자가 보는 결과만 검증. 내부가 바뀌어도 결과가 같으면 통과.
- 단위의 경계를 너무 좁게 잡지 마라 —
useCounter한 개가 아니라, 그걸 쓰는 컴포넌트를 함께 테스트. 의외로 더 안정적.
테스트 트로피 — 더 모던한 그림 #
Kent C. Dodds가 제안한 그림이 이런 결을 담습니다.
┌──────────────┐
│ E2E │ ← 핵심 시나리오 몇 개
├──────────────┤
│ │
│ 통합 │ ← 가장 많이
│ │
├──────────────┤
│ 단위 │ ← 복잡한 로직만
├──────────────┤
│ 정적 │ ← TS, ESLint, 타입 체크
└──────────────┘피라미드와 두 가지가 다릅니다.
- 정적 분석(타입 체커, 린터)이 가장 아래. TypeScript / ESLint가 잡는 것은 테스트가 잡으려 들지 마라. 무료로 얻는 가장 강한 안전망.
- 통합이 가장 두꺼운 층. “여러 컴포넌트가 함께 도는 영역"이 사용자의 시각과 가장 가깝고, 리팩터링에 견딜 만큼 안정적.
이 글의 시리즈는 트로피 쪽 시각을 따릅니다. React/Next.js 같은 프런트엔드 영역에서는 “단위” 와 “통합” 의 경계가 모호해서, 사실 거의 모든 RTL 테스트가 통합에 가까워요.
무엇을 테스트할지 — 결정 트리 #
도움이 되는 한 가지 트리.
이 코드가 깨지면 사용자가 알아챌까?
├─ 아니오 → 테스트하지 마라 (또는 정적 분석에 맡겨라)
└─ 예 → 테스트해라
↓
어떤 층에서?
├─ 한 함수의 복잡한 알고리즘 → 단위
├─ 여러 모듈/컴포넌트의 상호작용 → 통합
└─ 핵심 사용자 시나리오 (회원가입, 결제 등) → E2E“사용자가 알아챌까” 가 큰 필터입니다. 내부 헬퍼 함수의 변수 이름 같은 건 신경 쓰지 말 것. 외부에서 관찰 가능한 결과만 테스트.
“behavior” 를 테스트한다는 게 무엇인가 #
같은 컴포넌트를 두 가지 방식으로 테스트해 봅니다.
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label>
이메일
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</label>
<label>
비밀번호
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit">로그인</button>
</form>
);
}implementation에 묶인 테스트 (안티패턴):
test('email state updates', () => {
const { container } = render(<LoginForm onSubmit={jest.fn()} />);
const input = container.querySelectorAll('input')[0];
fireEvent.change(input, { target: { value: 'a@b.com' } });
// 내부 state 를 검증하려는 시도 ...
});querySelectorAll('input')[0]의 인덱스는 컴포넌트가 약간만 바뀌면 깨져요. label 추가, 순서 변경, 다른 input 추가.
behavior에 집중한 테스트:
test('사용자가 폼을 채워 제출하면 onSubmit 이 값과 함께 호출된다', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText('이메일'), 'a@b.com');
await userEvent.type(screen.getByLabelText('비밀번호'), 'secret');
await userEvent.click(screen.getByRole('button', { name: '로그인' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'a@b.com',
password: 'secret',
});
});차이가 명확합니다.
- 입력을 인덱스가 아니라 라벨로 찾음 → 사용자가 화면에서 폼을 채우는 방식과 같음.
- 버튼을 텍스트가 아니라 role + name으로 찾음 → 접근성 친화적.
- 검증은 외부에 노출된
onSubmit콜백만 → 내부 state의 이름이 바뀌어도 깨지지 않음.
이 시리즈의 모든 컴포넌트 테스트는 두 번째 형태로 갑니다.
모킹의 역할과 한계 #
테스트에서 외부 의존(API, DB, time, 랜덤)은 보통 모킹(mocking) 합니다. 이게 왜 필요한가:
- 외부 호출은 느리고, 네트워크에 의존하고, 비용이 있음.
- 외부 응답이 변할 수 있어서 테스트가 flaky 해짐.
- “특정 에러 응답” 같은 시나리오는 실제 시스템에서 만들기 어려움.
그러나 모킹은 위험한 도구 입니다.
- 너무 많이 모킹하면 — 테스트가 실제 시스템과 동떨어져요. “테스트는 다 통과했는데 프로덕션은 깨졌다” 의 가장 흔한 원인.
- 너무 깊은 곳을 모킹하면 — 모킹과 실제 구현이 따로 진화함. 한쪽이 바뀌어도 다른 쪽이 못 따라가는 사고.
원칙:
- 외부 시스템 경계만 모킹 — HTTP 호출, DB 연결, 파일 시스템.
- 내부 모듈은 거의 모킹하지 않는다. 단위 테스트 안에서 다른 모듈의 결과를 그대로 쓰는 게 통합 테스트로 자연스럽게 흘러감.
- HTTP 모킹은 #4 MSW — fetch를 모킹하는 게 아니라 네트워크 레이어에서 가로채요. 코드 입장에서는 진짜 fetch를 부른 거나 다름없음.
“테스트를 먼저” 인가 “테스트를 나중” 인가 #
TDD 논쟁은 끝없는 주제입니다. 여기서는 결만 짚습니다.
TDD가 잘 통하는 경우:
- 명확한 알고리즘, 입출력이 분명한 함수 (parser, validator, calculator).
- 버그 픽스 — 먼저 깨지는 테스트를 짠 뒤에 고치면, 그 버그가 다시 안 돌아옴.
TDD가 어색한 경우:
- UI가 어떻게 생길지 결정되지 않은 컴포넌트.
- 외부 API의 실제 응답 모양을 잘 모르는 통합 코드.
- 탐색적 코딩 (이 라이브러리를 쓰는 게 맞나 시도해 보는 단계).
이 시리즈는 TDD를 강요하지 않습니다. **“테스트가 있어야 할 곳에 있는가”**가 더 중요한 질문입니다.
커버리지의 함정 #
커버리지(coverage)는 “테스트가 코드의 몇 %를 실행했는가” 를 보여주는 숫자입니다. 90% 같은 목표를 잡고 싶어지지만, 함정이 둘 있습니다.
- 커버리지 90% 가 품질 90%를 뜻하지 않음 — 그냥 실행한 것일 뿐, 검증한 게 아님.
expect가 한 줄도 없는 테스트도 커버리지는 높음. - 마지막 10% 가 가장 비쌈 — error path, edge case, 자주 안 도는 분기들. 여기에 시간을 쏟다 보면 테스트가 안티패턴으로 흐르기 쉽습니다.
권장하는 시각:
- 새 코드는 자연스럽게 80% 정도 커버되도록 짜기. 안 되는 부분은 보통 의미 없는 코드(데드 코드, 불필요한 분기).
- 분기/줄 단위 커버리지보다 시나리오 커버리지를 본다. “회원가입 → 인증 메일 → 로그인” 같은 핵심 흐름이 다 테스트되는가.
- 커버리지 100% 를 목표로 하지 말 것. 주요 흐름이 잡혔다면, 마지막 줄까지 커버하려는 시도가 더 큰 손실입니다.
#6 에서 CI의 커버리지 리포트를 다시 짚습니다.
시간의 분배 — 결국 이 트랙의 결론 #
테스트는 시간의 분배에 관한 이야기입니다. 무한한 시간이 있으면 모든 라인을 단위/통합/E2E로 다 덮을 수 있겠지만, 현실은 그렇지 않습니다. 그러면 어디에 시간을 쓸까:
- 정적 분석 (타입, 린터) — 거의 무료. 켜둘 것.
- 통합 테스트 (RTL + MSW) — 가장 가성비 좋은 영역입니다. 시간의 절반 이상.
- 단위 테스트 — 복잡한 로직만 (calculator, parser, validator).
- E2E — 핵심 사용자 흐름 5~10개. 그 이상 늘리면 유지보수가 부담.
이 분배가 트랙 6편의 골격을 잡습니다.
#1 — 그림 잡기 (이번 글)
#2 — 단위 테스트 도구 (Vitest)
#3, #4, #5 — 통합 테스트 (RTL + MSW + userEvent)
#6 — E2E (Playwright) + CI시리즈 시작 — 무엇을 들고 있어야 하나 #
다음 글부터 손을 움직입니다. 들고 있어야 할 것:
- Node 22+ (Vitest가 모던 ESM/타입을 깔끔히 다루기 위함)
- pnpm 또는 npm
- TypeScript가 익숙하면 좋지만 필수는 아님 — TS 트랙이 충분한 디딤돌
도구 셋업과 첫 테스트는 #2 Vitest 에서.
정리 #
- 테스트가 안 되는 이유는 보통 “바빠서” 가 아니라 무엇/어디서/어떻게의 그림이 없어서.
- 피라미드보다 트로피 — 정적 분석 → 통합 → 단위 → E2E 순으로 비중을 분배. 통합이 가장 두꺼운 층.
- behavior를 테스트할 것. implementation에 묶이면 리팩터링이 두려워짐.
- 모킹은 시스템 경계 에서만. 내부 모듈을 모킹하면 테스트와 실제가 따로 진화함.
- 커버리지 숫자에 매달리지 말 것. 핵심 시나리오가 잡혔는지가 더 중요합니다.
- 트랙은 정적 분석 → Vitest 단위 → RTL+MSW 통합 → Playwright E2E의 흐름을 정리합니다.
다음 글(#2 Vitest 셋업과 첫 단위 테스트)에서는 도구를 설치하는 단계에 손을 댑니다. Vitest를 프로젝트에 붙이고, 가장 단순한 함수에 첫 테스트를 짭니다. describe / it / expect의 의미와 watch 모드의 쓰임까지 정리합니다.