커버리지 0%에서 시작하기 : baseline과 delta로 만드는 테스트 문화

2026년 7월 22일

#front

프로젝트 규모가 커질수록 사이드이펙트의 위험도 함께 커집니다. 모노레포 구조의 레포지토리에서 shared 하위의 util 하나만 수정해도 영향이 어디까지 퍼질지 예측하기 어렵습니다.

지금까지는 E2E 테스트로만 대응하고 있었습니다. E2E 테스트 환경은 “사용자 관점에서 UX 플로우가 멈추지 않는지”를 잘 검증해주고 있지만, 프로젝트 안에서 관리되고 있는 hook과 util가 이슈없이 동작되고 있는지는 보장해주진 못합니다.

그래서 유닛 테스트 커버리지를 높여서 더 탄탄한 프로젝트를 만들어보자는 새로운 목표를 세웠습니다! 피쳐 작업을 마무리하면, 해당 작업을 사이드프로젝트처럼 진행하였습니다.

작업 시작 전, 고민

커버리지 0% 에서 N% 로의 상승을 목표로 할 수 있을까?

막상 목표를 잡고 작업을 시작하려니 감이 안잡혔습니다. 작업을 시작할 때 프로젝트의 유닛 테스트 커버리지는 0 이었습니다.

이 상태에서 “커버리지 80%를 달성하자”는 목표를 세우자니, 성취감이 떨어지는 일이었습니다. 수많은 파일들 중에서 테스트를 몇 개 작성해도 0.5%, 1% … 올랐기 때문입니다.

그래서 접근 방식을 바꿔보았습니다. N% 라는 절대적인 숫자가 아닌 변화량(delta) 을 추적하는 것으로요.

  • 지금 커버리지가 몇 %인가? 👎
  • 이번 작업으로 커버리지가 올랐는가, 떨어졌는가? 👍

이렇게 방향을 잡다보니 목표는 테스트 시작 이전보다 떨어지지만 않으면 된다로 정리할 수 있었습니다. 그리고 테스트를 하나 추가할 때마다 리포트에 초록색 + 숫자가 찍히니까, 성취감도 비교적 높아집니다. 해당 구조는 커버리지 리챗 패턴이 적용된 방향이었습니다.

  • 커버리지 래칫(ratchet) : 래칫 기어처럼 한방향, 상승으로만 돌아가게 만드는 전략

플래닝

해당 프로젝트는 총 4단계로 구성했습니다.

  • coverage 리포트 포맷 통일 > 패키지별 Vitest 설정 > 루트 aggregate 스크립트 > 커스텀 스크립트 3종

Jest, Vitest 세팅에 json-summary 리포터를 추가합니다. 이 포맷에 맞게 테스트 결과가 리포트로 나올 것 입니다. 팀 취향에 맞게 정리할 수 있어요. 다음은 테스트가 필요한 디렉터리 하위에 세팅을 추가하는 것입니다. (저는 config, hook, model 디렉터리에 세팅하였습니다)

이후, 프로젝트 루트에 있는 package.json 에 커버리지를 측정하기 위한 스크립트를 추가합니다. 이 스크립트는 baseline 저장, 변화량 비교, gap 탐색 을 담당하는 Node 스크립트일 것 입니다. 가장 중요하겠죠?

스크립트 : baseline / diff / gap

해당 프로젝트의 핵심인 스크립트입니다. 각각 역할이 나뉘어져 있어요.

  1. coverage-baseline.js : 커버리지 측정 기준점 저장

유닛테스트를 실행하여 얻은 커버리지 정보를 읽어 하나의 coverage-baseline.json 파일로 스냅샷을 저장합니다. 저장 시간 및 패키지별 수치를 함께 기록하는 구조입니다.

...
const baseline = { savedAt: new Date().toISOString(), packages: {} };

for (const { label, path: coveragePath } of COVERAGE_PATHS) {
  const summary = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
  baseline.packages[label] = summary;
}
...

해당 스크립트 기준점을 만드는 용도이기 때문에, 자주 실행하지 않는다는 룰이 있습니다. 처음 한 번(0% 상태로) 저장하고, 이후에는 수동으로 실행합니다. baseline을 계속해서 업데이트하면 커버리지 상승률 추적에 영향을 받기 때문입니다.

  1. coverage-diff.js : 변화량 추적

baseline과 현재 커버리지를 비교해서 Lines / Functions / Branches의 delta를 출력합니다. 파일 단위 변화까지 감지하고 있어, 새로 커버리지 대상에 들어온 파일은 [NEW] 태그로, 수치가 바뀐 파일은 delta와 함께 보여줍니다. (= 유틸함수, 환경변수 추가 등을 의미합니다!)

const delta = getDelta(currentTotal, baseTotal ?? {});
if (Object.values(delta).some((v) => v < -0.05)) hasDrop = true;
// ...
if (enforce && hasDrop) {
  console.error("커버리지가 baseline보다 떨어졌습니다.");
  process.exit(1);
}

여기서 v < -0.05 코드는 허용 오차의 수치입니다. 파일 라인수, 부동소수점 계산 등의 작은 변화가 생겨도 영향이 간다고 하여, 이정도의 범위는 오차를 허용하기로 했습니다.

  1. coverage-gap.js : 다음 테스트 대상 찾기

다음 테스트는 어디에 추가할지 탐색하는 스크립트입니다. 유닛테스트 실행하여 얻은 커버리지를 파싱하고, 임계값인 80% 미달의 파일들을 리포트해줍니다.

const PRIORITY_PATTERNS = [
  { pattern: /services\/.+\/hooks\//, weight: 10, label: "Service Hook" },
  { pattern: /services\/.+\.query\./, weight: 9, label: "Query" },
  { pattern: /common\/hooks\//, weight: 8, label: "Common Hook" },
  { pattern: /configs\/utils\//, weight: 7, label: "Util" },
  // ...
];

또한 탐색 스크립트에는 중요도 또한 세팅하였습니다.

서비스 로직과 직결되는 hook과 query가 가장 높은 중요도를 가지고 있고, 중요도를 가지고 있는 순서대로 세팅이 되어있습니다. 커버리지의 퍼센티지만 보고 리포트해주는 것이 아닌, 이슈가 났을 때 가장 치명적인 곳 을 먼저 탄탄하게 하기위한 의도입니다.

사용 워크플로우

아래처럼 스크립트를 실행하여 동작합니다.

  • 비교를 위한 baseline 저장 > 테스트 작성 후 커버리지 파싱 > baseline 과 비교하여 얼마나 상승했는지 리포트
# 1. 최초 1회 — 현재 상태(0%)를 baseline으로 저장
pnpm coverage:baseline

# 2. 테스트 작성 후 커버리지 실행
pnpm test:coverage:all

# 3. 얼마나 올랐는지 확인
pnpm coverage:diff

husky pre-push 연동을 통한 자동화

위 작업은 직접 실행하여, 결과를 리포트 받는 도구처럼 사용합니다. 하지만 저는 이를 자동화하기로 하였습니다. 허스키의 pre-push hook 을 함께 연동하여, push 를 할 때마다 커버리지를 검사하게끔 하였어요. push 를 하면 이제 터미널에서 아래같은 리포트를 받을 수 있습니다.

========================================================================
📈 Coverage Delta Report
   baseline: 2026-04-23T06:05:14.907Z
========================================================================

📦 shared/configs — Total
  Lines     : 0.0%   (  0.0%)
  Functions : 50.0%  (+1.5%)
  Branches  : 50.0%  (+1.5%)

  변경된 파일 (2개):
  [NEW] shared/configs/utils/~~.ts
         Lines 0.0%  Functions +100.0%  Branches +100.0%

테스트를 추가한 파일엔 보통 초록색 +100.0%가 찍힙니다. 반대로 커버리지가 떨어진 상태로 push하려 하면 빨간색 -n%가 찍힐 것 입니다.

커버리지 관리에 민감한 프로젝트로 만들려면 --enforce가 걸어 push를 막아버리는 방법도 있습니다.

CI 가 아닌 로컬 훅을 활용한 이유

자동화를 의도한 것이면 Github Action 에서 관리하면 되지 않나?

저는 의도적으로 로컬 husky pre-push부터 시작했습니다.

  • CI에서 실패하면 피드백 루프가 push → CI 대기 → 실패 확인 → 수정으로 길어지지만, pre-push는 push 직전에 즉시 알 수 있습니다.

강제보다는 습관 형성이 먼저라고 생각했고, push 할 때마다 delta 리포트를 보는 것이 팀의 커버리지 향상을 위한 테스트 문화에 구축에 도움이 된다고 생각했습니다.

마치며

이번 프로젝트에서 N% 달성이라는 수치적 목표가 아닌, “떨어지지 않으면 된다” 라는 관점과 바로바로 보이는 피드백은 작업에 대한 큰 동기부여가 되었습니다. 또한 push 할 때마다 보이는 커버리지의 변화는 팀원들이 유닛테스트 커버리지에도 관심을 갖게 해주는 장치처럼 동작했습니다.

프로젝트 중간에 테스트 도입을 목표로 하는 분들에게도 이러한 접근이 도움이 되길 바랍니다!


Profile picture

주희(Joy)
가치를 고민하는 과정을 함께해요