테스팅 강좌 #1 왜 테스트인가 — 단위/통합/E2E의 역할

9 분 소요

리액트 트랙타입스크립트 트랙 을 마치고 자연스럽게 부딪히는 주제 — 테스트 입니다. 도구는 많고(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가 깨지면 “어디서” 깨졌는지 추적이 어려움.

그러나 — 단위만으로는 안 잡히는 부분이 있다 #

피라미드를 너무 곧이곧대로 받아들이면 함정에 빠집니다. 대표적인 안티패턴:

implementation 에 묶인 단위 테스트
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개 붉어지면, 다시는 리팩터링을 안 하게 됩니다.

해결의 방향은 두 가지.

  1. behavior를 테스트하라 — 사용자/호출자가 보는 결과만 검증. 내부가 바뀌어도 결과가 같으면 통과.
  2. 단위의 경계를 너무 좁게 잡지 마라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 모드의 쓰임까지 정리합니다.

X