실무 팁 — 현실적으로 테스트 유지하는 법




6편까지 4가지 테스트 유형과 CI/CD 자동화를 모두 다뤘다. 기술적으로는 준비가 끝났다. 하지만 테스트의 진짜 어려움은 “작성하는 것”이 아니라 “유지하는 것”이다.

처음에는 의욕이 넘쳐서 테스트를 잔뜩 작성한다. 하지만 시간이 지나면 테스트가 느려지고, 깨지는 테스트를 고치는 데 시간을 쏟고, “이거 의미 있나?” 싶은 순간이 온다. 이 편에서는 그 지점을 넘기기 위한 실무 전략을 정리한다.

무엇부터 테스트할 것인가

“전부 다 테스트하겠다”는 마음으로 시작하면 지치고, 결국 아무것도 안 하게 된다. 우선순위를 정하는 것이 중요하다.

가장 먼저 테스트해야 하는 것은 돈과 관련된 로직이다. 결제 금액 계산, 할인 적용, 수수료 산출, 정산 로직 같은 것들이다. 여기서 버그가 나면 금전적 손실이 발생하고, 사용자 신뢰가 무너진다. 이런 로직은 단위 테스트로 촘촘하게 작성해야 한다.

두 번째는 버그가 났던 부분이다. 버그를 수정할 때 “이 버그를 다시 발생시키는 테스트”를 먼저 작성하고, 그 테스트가 실패하는 것을 확인한 뒤, 코드를 고쳐서 통과시킨다. 이렇게 하면 같은 버그가 재발하는 것을 원천 차단할 수 있다. 이 습관 하나만 들여도 시간이 지날수록 테스트가 자연스럽게 쌓인다.

세 번째는 자주 변경되는 핵심 로직이다. 코드를 수정할 때마다 “다른 곳은 안 깨졌겠지?”라는 불안감이 드는 부분이 있다. 그 불안감이 드는 곳에 테스트를 작성하면 된다. 테스트가 있으면 수정 후 몇 초 만에 확인이 끝나니까.

네 번째는 유틸리티 함수다. 입력과 출력이 명확해서 테스트 작성이 가장 쉽다. 테스트 작성에 익숙해지는 연습 대상으로도 좋다.

반대로 후순위로 둬도 되는 것들이 있다. 단순한 UI 렌더링, 외부 라이브러리를 감싼 얇은 래퍼, 거의 변경되지 않는 설정 코드 같은 것들이다. 이런 곳에 테스트를 다는 것은 비용 대비 효과가 낮다.

테스트 커버리지

테스트 커버리지는 전체 코드 중 테스트가 실행하는 코드의 비율이다. Vitest에서 간단하게 측정할 수 있다.

npm install -D @vitest/coverage-v8
npx vitest run --coverage

실행하면 이런 결과가 나온다.

파일                    | 라인   | 함수   | 분기
src/utils/formatPrice.ts | 100%  | 100%  | 100%
src/utils/calcTax.ts     | 100%  | 100%  | 100%
src/utils/cart.ts        |  45%  |  60%  |  30%
src/components/Login.tsx |  72%  |  80%  |  65%

커버리지를 보면 어디에 테스트가 부족한지 한눈에 파악할 수 있다. 하지만 여기서 함정이 있다.

100%를 목표로 하면 안 된다. 커버리지 100%를 달성하기 위해 의미 없는 테스트를 양산하게 되고, 테스트 유지 비용만 늘어난다. getter/setter 하나하나를 테스트하거나, 단순한 렌더링만 확인하는 테스트를 잔뜩 만드는 것은 시간 낭비다.

현실적인 목표는 핵심 비즈니스 로직 80% 이상, 전체 프로젝트 60~70% 수준이다. 커버리지 숫자보다 중요한 것은 “중요한 로직이 테스트되고 있는가”다. 커버리지가 90%인데 결제 로직에 테스트가 없다면, 커버리지 50%인데 결제 로직이 촘촘하게 테스트된 프로젝트보다 위험하다.

CI에서 커버리지를 자동으로 체크하고 싶다면 vitest.config.ts에 설정을 추가할 수 있다.

export default defineConfig({
  test: {
    coverage: {
      reporter: ['text', 'html'],
      thresholds: {
        statements: 60,
        branches: 60,
        functions: 60,
        lines: 60,
      },
    },
  },
});

thresholds를 설정하면 커버리지가 기준 이하로 떨어질 때 테스트가 실패한다. 처음에는 낮게 잡고, 테스트가 쌓이면서 점진적으로 올리는 것이 좋다.

테스트 속도 관리

테스트가 느려지면 개발자가 안 돌리게 된다. “잠깐 기다려야지” 하다가 “나중에 돌려야지”가 되고, 결국 “CI에서 돌아가니까 됐지”가 된다. 이 시점이 오면 테스트의 피드백 루프가 끊긴 것이다.

속도를 유지하기 위한 전략이 몇 가지 있다.

첫째, 단위/통합 테스트와 E2E 테스트를 분리해서 실행한다. 개발 중에는 npm test로 단위/통합 테스트만 watch 모드로 띄워두고, E2E는 push할 때 CI에서 돌린다. 단위/통합 테스트는 보통 수백 개가 있어도 10초 이내에 끝나므로, 코드를 저장할 때마다 부담 없이 돌릴 수 있다.

둘째, Vitest의 watch 모드를 활용한다. watch 모드는 변경된 파일과 관련된 테스트만 골라서 다시 실행한다. 전체 테스트 스위트가 500개라도, 수정한 파일과 관련된 5개만 실행되니 피드백이 즉각적이다.

셋째, CI에서 테스트를 병렬로 실행한다. 6편에서 lint-and-test와 e2e를 별도 Job으로 분리한 것도 이 전략의 일부다. Job이 더 많아지면 GitHub Actions의 matrix 기능으로 테스트를 여러 서버에서 동시에 돌릴 수도 있다.

넷째, 무거운 테스트를 점검한다. 특정 테스트가 유독 느리다면, 그 테스트가 정말 E2E여야 하는지 재검토한다. 통합 테스트로 대체할 수 있는 경우가 의외로 많다.

스냅샷 테스트

스냅샷 테스트는 컴포넌트의 렌더링 결과를 파일로 저장해두고, 이후 변경이 생기면 알려주는 방식이다.

import { render } from '@testing-library/react';
import Button from './Button';

test('Button 스냅샷', () => {
  const { container } = render(<Button label="확인" />);
  expect(container).toMatchSnapshot();
});

처음 실행하면 __snapshots__ 폴더에 스냅샷 파일이 생긴다. 다음 실행부터 렌더링 결과가 달라지면 테스트가 실패한다. 의도한 변경이면 vitest run --update로 스냅샷을 업데이트하면 된다.

스냅샷 테스트의 장점은 작성이 매우 쉽다는 것이다. toMatchSnapshot() 한 줄이면 끝이다. 의도치 않은 UI 변경을 빠르게 잡아낼 수 있다.

하지만 단점도 명확하다. 스냅샷이 깨지면 대부분의 개발자가 내용을 확인하지 않고 바로 업데이트한다. “뭐가 바뀌었는지”를 diff로 확인해야 하는데, diff가 길면 그냥 업데이트 버튼을 누르게 된다. 이렇게 되면 테스트가 있으나 마나 한 상태가 된다.

권장하는 사용법은 두 가지다. 남용하지 않고 핵심 UI 컴포넌트에만 제한적으로 사용하는 것이 하나이고, 큰 컴포넌트 전체보다는 특정 부분만 스냅샷으로 찍는 것이 다른 하나다.

test('에러 상태의 메시지 구조', () => {
  const { container } = render(<ErrorMessage code={404} />);
  expect(container).toMatchSnapshot();
});

전체 페이지를 스냅샷으로 찍으면 조금만 변경해도 깨지고, diff도 너무 길어서 확인할 엄두가 나지 않는다. 작고 독립적인 컴포넌트에 쓰는 것이 효과적이다.

TDD 맛보기

TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 작성하는 방식이다. 순서가 일반적인 개발과 반대다.

일반적인 흐름은 “코드 작성 → 테스트 작성 → 확인”이다. TDD의 흐름은 “테스트 작성(실패) → 최소한의 코드로 통과 → 리팩토링 → 반복”이다.

예를 들어 장바구니의 총액을 계산하는 함수를 TDD로 만든다고 하자.

먼저 테스트를 작성한다. 아직 함수가 없으니 당연히 실패한다.

import { calcTotal } from './cart';

test('상품 가격의 합계를 반환한다', () => {
  const items = [
    { name: '키보드', price: 50000, quantity: 1 },
    { name: '마우스', price: 30000, quantity: 2 },
  ];
  expect(calcTotal(items)).toBe(110000);
});

이 테스트를 통과시키기 위한 최소한의 코드를 작성한다.

interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

export function calcTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

테스트가 통과한다. 이제 다음 요구사항에 대한 테스트를 추가한다.

test('빈 장바구니는 0을 반환한다', () => {
  expect(calcTotal([])).toBe(0);
});

test('할인이 적용된 상품을 처리한다', () => {
  const items = [
    { name: '키보드', price: 50000, quantity: 1, discount: 0.1 },
  ];
  expect(calcTotal(items)).toBe(45000);
});

세 번째 테스트는 실패한다. discount 필드를 처리하는 로직이 없으니까. 이제 이 테스트를 통과시키기 위해 코드를 수정한다. 이 사이클을 반복하면서 기능을 완성해나간다.

TDD의 장점은 “무엇을 만들어야 하는지”를 먼저 정의하고 시작하기 때문에, 불필요한 코드를 만들지 않게 된다는 것이다. 또한 기능이 완성되는 시점에 테스트도 이미 완성되어 있다.

모든 곳에 TDD를 적용할 필요는 없다. 복잡한 비즈니스 로직이나 알고리즘을 구현할 때 특히 효과적이고, 단순한 CRUD나 UI 작업에는 오히려 번거로울 수 있다. 먼저 일반적인 테스트 작성에 익숙해진 뒤에 TDD를 시도해보는 것을 추천한다.

테스트를 깨뜨리는 흔한 실수들

실무에서 테스트를 유지하다 보면 반복적으로 만나는 문제들이 있다.

첫 번째는 구현 세부사항을 테스트하는 것이다. 내부 state 값을 직접 확인하거나, 특정 함수가 몇 번 호출되었는지를 검증하는 테스트는 리팩토링할 때마다 깨진다. 사용자가 보는 결과를 기준으로 테스트해야 코드를 자유롭게 개선할 수 있다.

두 번째는 테스트 간 의존성이다. A 테스트가 만든 데이터를 B 테스트가 사용하면, A가 실패하거나 실행 순서가 바뀔 때 B도 깨진다. 각 테스트는 독립적으로 실행 가능해야 한다. 필요한 데이터는 각 테스트 안에서 직접 세팅하자.

세 번째는 너무 많은 것을 하나의 테스트에 넣는 것이다. 하나의 test 블록에서 10가지를 확인하면, 실패했을 때 어디가 문제인지 파악하기 어렵다. 하나의 테스트는 하나의 동작만 검증하는 것이 원칙이다.

네 번째는 테스트에 로직을 넣는 것이다. 테스트 코드 안에 if문이나 반복문이 들어가면, 테스트 자체에 버그가 생길 수 있다. 테스트 코드는 가능한 한 단순하고 직관적이어야 한다.

팀에 테스트 도입하기

혼자 테스트를 작성하는 것과 팀 전체에 테스트 문화를 도입하는 것은 다른 문제다. “우리도 테스트 합시다”라고 말하는 것만으로는 바뀌지 않는다.

가장 효과적인 방법은 작은 성공 경험을 만드는 것이다. 먼저 본인이 테스트를 작성하고, 그 테스트가 실제로 버그를 잡는 순간을 팀에 공유한다. “어제 이 테스트가 없었으면 프로덕션에 버그가 나갈 뻔했다”는 경험이 쌓이면 자연스럽게 팀원들이 관심을 갖게 된다.

코드 리뷰에서 테스트를 요구하는 것도 좋은 방법이지만, 처음부터 “테스트 없으면 머지 불가”로 가면 저항이 크다. “핵심 로직을 변경하는 PR에는 관련 테스트를 포함해주세요” 정도로 시작해서 범위를 점진적으로 넓히는 것이 현실적이다.

점진적 도입 로드맵

한 번에 다 하려 하면 부담스럽다. 아래 순서로 단계별로 도입하되, 각 단계에서 팀이 충분히 익숙해진 후에 다음으로 넘어가는 것이 중요하다.

1단계는 정적 테스트 강화다. TypeScript strict 모드를 켜고, ESLint를 엄격하게 설정하고, Prettier를 도입한다. 코드를 실행하지 않아도 잡을 수 있는 문제를 최대한 잡는다. 가장 진입 장벽이 낮고 효과는 즉각적이다.

2단계는 핵심 유틸 함수에 단위 테스트를 작성하는 것이다. 금액 계산, 날짜 변환, 유효성 검사 같은 순수 함수부터 시작한다. 테스트 작성에 익숙해지는 연습 단계다.

3단계는 GitHub Actions로 CI를 연결하는 것이다. 린트, 타입 체크, 단위 테스트가 push할 때마다 자동으로 돌아가는 환경을 만든다. 이 시점부터 테스트가 “있으면 좋은 것”에서 “개발 프로세스의 일부”로 바뀐다.

4단계는 주요 기능에 통합 테스트를 추가하는 것이다. MSW를 세팅하고, 컴포넌트와 API 연동을 테스트한다. 로그인, 핵심 데이터 조회/저장 같은 기능부터 시작한다.

5단계는 핵심 사용자 플로우에 E2E 테스트를 작성하는 것이다. 서비스에서 가장 중요한 시나리오 3~5개만 선정해서 Playwright로 작성한다. 전부 다 하려 하지 않는다.

6단계는 커버리지 측정과 PR 머지 차단을 설정하는 것이다. 커버리지 기준을 정하고, 테스트 통과 없이는 머지가 불가능하도록 브랜치 보호 규칙을 설정한다. 이 시점이 되면 테스트 문화가 팀에 자리잡은 것이다.

각 단계 사이에 서두르지 않는 것이 핵심이다. 1단계를 적용하고 2주 정도 운영해본 뒤, 팀이 불편 없이 적응했으면 2단계로 넘어가는 식이다.

마무리

이 시리즈에서 다룬 내용을 전체적으로 정리하면 이렇다.

1편에서 테스트의 4가지 유형을 이해했다. 정적 테스트, 단위 테스트, 통합 테스트, E2E 테스트가 각각 어떤 역할을 하고, 어떤 비율로 가져가야 하는지를 배웠다.

2편에서 정적 테스트를 세팅했다. TypeScript strict 모드, ESLint, Prettier, 그리고 husky를 활용한 3중 안전장치를 구축했다.

3편에서 Vitest로 단위 테스트를 작성했다. 유틸 함수와 컴포넌트를 테스트하는 방법, 그리고 어떤 케이스를 테스트해야 하는지를 배웠다.

4편에서 MSW로 통합 테스트를 작성했다. API를 모킹해서 컴포넌트와 API의 연동을 검증하고, Next.js Route Handler도 테스트했다.

5편에서 Playwright로 E2E 테스트를 작성했다. 실제 브라우저에서 사용자 시나리오를 자동화하고, 디버깅 도구를 활용하는 방법을 배웠다.

6편에서 GitHub Actions로 전체 테스트를 자동화했다. push/PR 시 모든 테스트가 자동 실행되고, 실패하면 머지가 차단되는 파이프라인을 구축했다.

그리고 이번 7편에서 실무에서 테스트를 유지하기 위한 전략과 팀에 점진적으로 도입하는 로드맵을 정리했다.

가장 중요한 것은 완벽한 테스트가 아니다. 돌아가는 테스트를 꾸준히 유지하는 것이다. 테스트가 하나도 없는 상태에서 하나를 추가하는 것이, 테스트 100개를 작성하고 유지를 포기하는 것보다 낫다. 작게 시작하고, 꾸준히 쌓아가자.




댓글 남기기