개발 기술/개발 이야기

테스트 코드를 관리하는 법 2: 커버리지 감소 검사하기

by GicoMomg 2025. 1. 24.

0. 들어가며…

  • 지난 시간에는 테스트 코드를 관리하는 방법 중 하나로, “pre-push 단계에서 테스트 실패를 체크하는 방법”을 알아보았다.
  • 이 방법의 경우, 코드 수정시 발생하는 오류를 테스트 검증으로 막을 수 있었다.
  • 하지만 서비스는 끊임없이 변화하고 개선된다. 이 상황에서 추가 기능의 테스트 코드가 작성되지 않는다면, 리뷰 단계에서 놓치기 쉽다.
  • 그렇다면, 기존 소스 코드에 기능을 추가할 때 테스트 코드 를 어떻게 관리할 수 있을까?
  • 그 해답은 develop(베이스 브랜치)을 기준으로 수정한 파일에 한해 커버리지를 비교하는 방법이다.
  • 이 방법을 사용하면 기능을 추가한 뒤 해당 파일의 커버리지가 감소한 경우 PR 코멘트로 알려주고, 병합을 막을 수 있다.
  • 이번 글에서는 “테스트 코드를 관리하는 법” 시리즈의 마지막 주제로, 테스트 커버리지 감소를 체크하는 방법에 대해 알아보겠다.
  • 이 글을 읽고 나면, 커버리지 변화를 코멘트로 볼 수 있고, 커버리지가 감소한 경우 병합을 막는 방법을 알 수 있다.

< 커버리지가 감소한 경우, 코멘트 예시 >



< 커버리지가 감소하지 않은 경우, 코멘트 예시 >


목차

1. 커버리지 감소를 체크하는 방법
   1) github 사전 설정: 브랜치 룰 지정하기
   2) 동작 컨셉
   3) 프로젝트 구조
   4) 코드 자세히보기
   5) 테스트 해보기
2. 마치며





1. 커버리지 감소를 체크하는 방법

1) github 사전 설정: 브랜치 룰 지정하기

워크플로우를 설정하기 전에, develop 브랜치의 병합을 제한하는 룰을 정의하자!

(1) 설정 페이지로 이동하기

  • 우선, 깃헙 레포에서 [Settings] → [Rulesets] → [New ruleset]로 이동하자.


(2) 룰을 지정할 대상 브랜치 추가하기

  • 먼저, 룰을 지정할 브랜치를 develop 으로 지정한다.


(3) 삭제 방지 룰: develop 브랜치 삭제 차단하기

  • develop 브랜치를 삭제할 수 없도록 제한한다.


(4) develop 병합 차단 룰: 승인을 받은 경우만 병합 허용하기

  • 특정 승인 조건을 만족한 경우에만 병합을 허용하는 룰이다.

  • (a) PR을 merge하려면 최소 1명 이상의 approve가 필요하도록 설정할 수 있다.
  • (b) approve 후 커밋이 추가되면, approve가 취소된다.

(5) develop 병합 차단 룰: Job이 성공한 경우만 병합 허용하기

  • 특정 job 이 성공한 경우에만 병합을 허용하는 룰이다.

  • (a) test-and-compare(커버리지 비교하는 작업) “job”이 성공해야만 merge를 허용한다.




2) 동작 컨셉

커버리지 감소를 체크하는 방식은 다음과 같이 구성했다.

(1) 브랜치 조건

  • develop 브랜치 기준으로 분기한 브랜치에만 적용된다.


(2) 스크립트 작동 시점

  • PR이 approve되면 워크플로우 스크립트가 실행된다.

(3) 커버리지 비교 방식

  • 대상 파일: develop 기준으로 수정된 파일들.
  • 비교 기준: develop 브랜치와 현재 원격 브랜치의 테스트 커버리지를 비교한다.

(4) 결과에 따른 동작

  • 커버리지가 감소한 경우, merge를 차단한다.
  • 커버리지가 같거나 증가한 경우, merge를 허용한다.




3) 프로젝트 구조

(1) 프로젝트 전체 구성

프로젝트 구조는 다음과 같다:

  • 메인 동작 코드 관리: 메인 코드는 project/ 폴더 아래에 위치한다.
  • 스크립트 및 설정 파일: CICD 스크립트와 husky 설정은 루트 디렉토리에 배치된다.
# 프로젝트 구조 예시

your-project/
├── .github/workflows
│   ├── develop-coverage.yml      # CICD 워크플로우
├── scripts                       # 스크립트 폴더
│   ├── compare-all-coverage.ts
│   ├── git-file-tracker.ts
│   └── pre-push.ts
│
├── project/                      # 메인 코드
│   ├── folder1/
│   │   ├── folder2/
│   │   │   ├── A.vue
│   │   │   └── B.vue
│   └── ...
│
├── tests/                        # 테스트 코드
│   ├── folder1/ 
│   │   ├── folder2/
│   │   │   ├── A.spec.js
│   │   │   └── B.spec.ts
│   └── ...
│
├── .husky/                       # Git hooks
│   └── pre-push
│
├── package.json
├── tsconfig.json
└── vitest.config.ts

(2) 테스트 코드의 위치

  • 테스트 코드의 경로는 소스 파일 디렉토리 구조를 기반으로 매핑된다.
  • 예를 들어, project/folder1/folder2/A.vuetests/folder1/folder2/A.spec.js 또는 A.test.ts 로 매핑된다.
your-project/
├── project/
│   ├── folder1/
│   │   ├── folder2/
│   │   │   ├── A.vue       # 소스 파일,(a)
│   │   │   └── B.vue       # 소스 파일,(b)
│   └── ...
├── tests/
│   ├── folder1/
│   │   ├── folder2/
│   │   │   ├── A.spec.js   # (a)의 테스트 코드
│   │   │   └── B.spec.ts   # (b)의 테스트 코드
│   └── ...

(3) 스크립트 코드의 위치 및 역할

  • 이 파일들을 이용해, 커버리지 감소를 감지하고 merge 여부를 결정한다.
your-project/
├── .github/workflows
│   ├── develop-coverage.yml 
├── scripts
│   ├── compare-all-coverage.ts  # 커버리지 비교 스크립트
│   ├── git-file-tracker.ts      # 수정된 파일 추적
│   └── ...
파일명 역할
develop-coverage.yml - develop 브랜치를 기준으로 approve된 PR에 대해 실행되는 CICD 워크플로우.
- scripts/compare-all-coverage.ts 스크립트를 호출함.
compare-all-coverage.ts - develop 브랜치와 현재 원격 브랜치의 커버리지를 비교
- 커버리지 감소 여부에 따라 merge를 제한함
git-file-tracker.ts - PR에서 수정된 파일 목록을 추적함
- 비교 대상 파일을 선정하는 데 사용함




4) 코드 자세히보기

그럼, 실제 코드는 어떻게 작성되었을까? 스크립트 파일을 살펴보자.
이 섹션에서 설명하는 코드는 이 레포에서 확인할 수 있다.

(1) 스크립트를 실행시키는 CICD, develop-coverage.yml

  • develop-coverage.ymlPR이 develop 브랜치에 병합되기 전에 커버리지를 비교하고
  • 비교 결과를 댓글로 남기도록 구성했다. (전체 코드보기)

A. 워크플로우 트리거 조건 설정하기

  • PR 리뷰가 제출된 경우(submitted), 워크플로우가 실행된다.
name: Coverage Checker on Develop

on:
  pull_request_review:
    types: [submitted] # PR 리뷰가 제출된 경우에 워크플로우 실행
    ...

B. 권한 설정

  • 워크플로우를 실행을 위해 필요한 GitHub 권한을 설정한다.
  • 해당 스크립트에는 커버리지 감소, 유지 여부를 댓글로 남기는 기능이 필요하다.
  • permissions(권한)에 write(작성) 권한을 부여해주자.
...
permissions
  contents: read
  issues: write
  pull-requests: write
  ...

C. job을 조건부로 실행하기

  • develop 브랜치를 대상으로 하고, PR이 approved 인 경우에만 job을 실행한다.
...
jobs:
  test-and-compare:
    if: github.event.pull_request.base.ref == 'develop' && github.event.review.state == 'approved'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
       ...

D. job을 실행하기 전, 환경 준비하기

  • job 을 실행하기 전에 node 환경을 설정한다. 이 과정을 거치지 않으면 npm 명령어를 사용할 수 없다.
...
jobs:
  test-and-compare:
    ...
    steps:
      - name: Checkout current branch  # (a) 현재 브랜치 체크아웃
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Set up Node.js        # (b) Node.js 환경 설정
        uses: actions/setup-node@v3
        with:
          node-version: 18

      - name: Install dependencies  # (c) 의존성 설치
        run: npm install
번호 설명
(a) 현재 PR의 브랜치를 체크아웃하여 작업 디렉토리로 설정.
(b) Node.js 환경을 설정하며, 여기서는 Node.js 버전 18을 사용.
(c) package.json의 의존성을 설치.

E. develop 브랜치의 커버리지 저장하기

  • develop 브랜치를 기준으로, 테스트 커버리지를 측정하고 별도 파일에 저장한다.
...
jobs:
  test-and-compare:
      ...
      - name: Run tests and save coverage on develop branch
        id: develop_coverage
        run: |
          git fetch origin develop   
          git checkout origin/develop -b develop  # (d)
          npx vitest --coverage       # (e)
          mkdir -p coverage-develop   # (f)
          cp coverage/coverage-summary.json coverage-develop/coverage-summary.json  # (f)
          ...
번호 설명
(d) develop 브랜치로 체크아웃.
(e) develop을 기준으로, 테스트 커버리지를 측정.
(f) 커버리지 파일(coverage-summary.json)을 coverage-develop 디렉토리에 저장.

F. 현재 브랜치의 커버리지 저장하기

  • 현재 원격 브랜치를 기준으로, 테스트 커버리지를 측정하고 별도 파일에 저장한다.
...
jobs:
  test-and-compare:
      ...
      - name: Checkout current branch again # (g)
         run: |
          git checkout -f ${{ github.event.pull_request.head.ref }}
          echo "Checked out branch ${{ github.event.pull_request.head.ref }}"

      - name: Run tests and save coverage on current branch 
        run: |
          npx vitest --coverage      # (h)
          mkdir -p coverage-current  # (i)
          cp coverage/coverage-summary.json coverage-current/coverage-summary.json # (i)
번호 설명
(g) 현재 원격 브랜치로 체크아웃.
(h) 원격 브랜치를 기준으로, 테스트 커버리지를 측정.
(i) 커버리지 파일(coverage-current.json)을 coverage-develop 디렉토리에 저장.

G. 커버리지 비교 스크립트 실행시키기

  • 원격과 develop의 테스트 커버리지를 비교하고, 비교 결과를 PR 댓글로 남긴다.
...
jobs:
  test-and-compare:
    ...
    steps:
      ...
      - name: Install ts-node and TypeScript # (j)
        run: npm install -D ts-node typescript

      - name: Compare coverage # (k)
        run: npx ts-node scripts/compare-all-coverage.ts

      - name: Comment coverage result to PR # (l) 
        if: always()
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs');
            const result = fs.readFileSync('coverage-diff-result.txt', 'utf-8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: result,
            });
번호 설명
(j) TypeScript 및 ts-node를 설치.
(k) 커버리지 비교 스크립트 실행.
(l) 커버리지 결과를 댓글로 추가.



(2) 커버리지를 비교하는, compare-all-coverage.ts

  • compare-all-coverage.ts는 develop 브랜치와 현재 원격 브랜치의 커버리지를 비교하고 결과를 생성하는 스크립트이다.(전체 코드보기)

A. main(), 스크립트의 주요 실행 흐름을 담당하는 함수

function main() {
  const baseCoveragePath = path.join('coverage-develop', 'coverage-summary.json');
  const currentCoveragePath = path.join('coverage-current', 'coverage-summary.json');

  // (a) 커버리지 파일 로드
  const baseCoverage = loadCoverageFile(baseCoveragePath);
  const currentCoverage = loadCoverageFile(currentCoveragePath);

  // (b) 변경된 파일 필터링
  const diffFiles = getBranchDiffFiles('develop');
  const modifiedFiles = diffFiles.filter((file) => file.startsWith(SRC_PREFIX));

  const resultLines: string[] = [];
  resultLines.push('## Coverage Diff Result');
  resultLines.push('비교 기준: develop branch vs. current branch\n');

  if (modifiedFiles.length > 0) {
    // (c) 커버리지 비교
    const diffReport = generateCoverageDiffReport({ fileList: modifiedFiles, baseCoverage, currentCoverage });
    resultLines.push(...diffReport);
  } else {
    console.log('No modified files within the target folder.');
  }

  if (!coverageDecreased) {
    resultLines.push('\n✅ 수정된 파일의 커버리지가 하락하지 않았습니다. Good job!');
  } else {
    resultLines.push('\n⚠️  일부 파일에서 커버리지가 감소했습니다. 테스트 보강이 필요할 수 있습니다.');
  }

  // (d) 리포트 저장
  fs.writeFileSync('coverage-diff-result.txt', resultLines.join('\n'), 'utf-8');

  // (e) 병합 차단
  if (coverageDecreased) {
    console.error('Coverage decreased. Merge is blocked.');
    process.exit(1);
  }
}
번호 설명
(a) develop 브랜치와 현재 브랜치의 커버리지 데이터를 로드.
(b) develop 브랜치와 비교해 수정된 파일만 추출.
(c) 수정된 파일에 대해 커버리지 차이를 계산하고 리포트 생성.
(d) 결과를 coverage-diff-result.txt 파일에 저장.
(e) 커버리지가 감소하면 병합을 차단.

B. loadCoverageFile() , 커버리지 파일을 읽어 JSON 객체로 반환하는 함수

function loadCoverageFile(filePath: string): CoverageSummary {
  // (a) 파일 체크
  if (!fs.existsSync(filePath)) {
    console.error(`Coverage file not found: ${filePath}`);
    process.exit(1);
  }

  // (b) 파싱
  const content = fs.readFileSync(filePath, 'utf-8');
  const parsedContent = JSON.parse(content);

  // (c) 데이터 변환
  return Object.keys(parsedContent).reduce((acc, key) => {
    acc[getFilePath(key)] = parsedContent[key];
    return acc;
  }, {} as CoverageSummary);
}
번호 설명
(a) 파일이 존재하는지 확인.
(b) 파일 내용을 읽어 파싱.
(c) 커버리지 데이터를 소스 경로 형식으로 변환.

C. getFilePath() , 프로젝트 경로를 추출하는 함수

  • 파일 경로에서 SRC_PREFIX를 기준으로 나머지 경로만 추출한다.
function getFilePath(filePath: string) {
  return `${SRC_PREFIX}${filePath.split(SRC_PREFIX)[1]}`;
}

D. generateCoverageDiffReport() , 두 커버리지 데이터를 비교하는 함수

  • fileList는 develop 기준으로 수정된 파일 목록이다.
  • fileList별로 develop, 원격 브랜치의 커버리지 데이터를 비교해 리포트를 생성한다.
function generateCoverageDiffReport(params: {
  fileList: string[];
  baseCoverage: CoverageSummary;
  currentCoverage: CoverageSummary;
}): string[] {
  const { fileList, baseCoverage, currentCoverage } = params;
  const result: string[] = [];
  const uniqueSourceFilePaths = new Set(
    fileList.map((file) => getSourceFilePath(coverageKeys, file))
  );

  // (a)
  for (const file of uniqueSourceFilePaths) {
    const sourceFilePath = getSourceFilePath(Object.keys(baseCoverage), file);
    const fileReport = compareCoverageMetricsForFile(sourceFilePath, baseCoverage, currentCoverage);

   // (b)
    result.push(...fileReport);
  }

  // (c)
  if (result.length > 0) {
    result.unshift('파일 | Metric | develop 커버리지 | current 커버리지 | 비고');
    result.splice(1, 0, '--- | --- | --- | --- | ---');
  }
  return result;
}
번호 설명
(a) 파일 리스트를 순회하며 각 파일의 커버리지 메트릭 비교
(b) 비교 결과를 배열로 반환.
(c) 결과 배열에 표 헤더 추가.

E. getSourceFilePath() , 테스트 파일 경로를 소스 파일 경로로 변환하는 함수

  • 커버리지 데이터는 테스트 대상의 파일 경로를 키값으로 가진다.
  • 그래서 테스트 코드를 수정한 경우, 테스트 코드의 파일 경로로 커버리지를 찾을 수 없다.
{
  "/Users/.../project/folder1/folder2/hello.js": {
    "lines":{ "total":27, "covered":9, "skipped":0, "pct":33.33 },
    "functions":{ "total":9, "covered":3, "skipped":0, "pct":33.33 },
    "statements":{ "total":27, "covered":9, "skipped":0, "pct":33.33 },
    "branches":{ "total":3, "covered":3, "skipped":0, "pct":100 }
  }
}

  • 이 경우, 테스트 파일에 대응되는 테스트 대상 파일 경로를 찾아야한다.
  • getSourceFilePath()은 주어진 파일이 테스트 코드 파일이라면, 그에 해당하는 테스트 대상 파일 경로를 반환한다.
function getSourceFilePath(coverageKeys: string[], file: string): string {
  if (file.startsWith(TEST_PREFIX)) {
    // (a)
    const fileDir = path.dirname(file);
    const fileBase = path.basename(file, path.extname(file)).split('.')[0];
    const formattedFile = path.join(fileDir, fileBase).replace(TEST_PREFIX, SRC_PREFIX);

    // (b)
    const idx = coverageKeys.findIndex((k) => k.includes(formattedFile));
    return idx > -1 ? coverageKeys[idx] : file;
  }
  return file;
}
번호 설명
(a) 테스트 파일의 디렉토리와 이름을 기반으로 소스 파일 경로 생성.
(b) 커버리지 키 리스트에서 해당 파일에 대한 데이터가 존재하는지 확인.

F. compareCoverageMetricsForFile(), 커버리지 메트릭을 비교하는 함수

function compareCoverageMetricsForFile(
  filePath: string,
  baseCoverage: CoverageSummary,
  currentCoverage: CoverageSummary
): string[] {
  const result: string[] = [];
  const baseData = baseCoverage[filePath] || null;
  const currentData = currentCoverage[filePath] || null;

  // (a)
  for (const metric of COVERAGE_METRICS) {
    const basePct = baseData ? baseData[metric].pct : 0;
    const currentPct = currentData ? currentData[metric].pct : 0;

    if (SKIP_UNCHANGED_COVERAGE && currentPct >= basePct) {
      continue;
    }

    // (b)
    const note = currentPct < basePct ? '⚠️  하락' : '✅  유지';
    if (currentPct < basePct) coverageDecreased = true;

    // (c)
    result.push(`${filePath} | ${metric} | ${basePct}% | ${currentPct}% | ${note}`);
  }
  return result;
}
번호 설명
(a) develop 브랜치와 현재 브랜치의 각 메트릭(lines, functions, branches, statements)을 비교.
(b) 커버리지가 감소한 경우 경고 메시지 추가.
(c) 비교 결과를 리포트 형식으로 배열에 저장.

(3) Git 브랜치 간 변경된 파일 목록을 추출하는, git-file-tracker.ts

  • git-file-tracker.tsGit 브랜치 간 변경된 파일 목록을 추출하기 위한 스크립트이다. (전체 코드보기)
export function getBranchDiffFiles(branch: string): string[] {
  try {
    const output = execSync(`git diff --name-only ${branch}...HEAD`) // (a)
      .toString()
      .split('\n')
      .map((f) => f.trim()) // (b)
      .filter(Boolean);
    console.log('Modified files from git diff:', output);
    return output;
  } catch (error) { // (c)
    console.error('Failed to get modified files:', error); 
    return [];
  }
}
번호 설명
(a) git diff --name-only 명령어를 사용해 변경된 파일 목록을 가져옴
(b) 명령어 출력 결과를 문자열로 변환하고, 파일 경로 배열에 저장한다.
(c) Git 명령어 실행에 실패한 경우, 에러를 출력하고 빈 배열을 반환함.




5) 테스트 해보기

(1) 커버리지가 감소하지 않은 경우

  • 1명 이상의 리뷰어가 PR 승인을 한다.


  • 그럼, 승인이 된 시점에 커버리지를 비교하는 스크립트가 동작한다.
  • 만약 커버리지가 감소하지 않았다면 아래와 같은 댓글이 달리며 병합도 가능하다.


(2) 커버리지가 감소한 경우

  • 하지만 기존 파일에 새로운 기능을 추가했다면 스크립트는 어떻게 동작할까?
  • 만약 커버리지가 감소했다면, 어떤 테스트 항목이 감소했는지 댓글로 알려준다.


  • 그리고 커버리지 측정 job(test-and-compare)이 실패했기에, 병합이 불가능한 상태가 된다. (예시 보러가기)




2. 마치며…

이번 시간에는 테스트 코드를 관리하는 법 시리즈의 두 번째로, 커버리지 감소를 검사하는 법을 알아보았다.

프로세스를 요약하면 다음과 같다:

  1. GitHub Actions 설정:
    • PR이 approve된 경우에만 커버리지를 비교하도록 워크플로우를 구성.
    • develop 브랜치를 기준으로 현재 브랜치의 커버리지와 비교.
  2. 커버리지 비교 스크립트:
    • compare-all-coverage.ts로 수정된 파일의 커버리지를 분석.
    • 변경된 파일 목록 추출(git-file-tracker.ts)과 메트릭 비교로 커버리지 차이를 리포트 생성.
  3. 자동화의 결과:
    • 커버리지가 감소한 경우 merge를 차단해 품질 저하 방지.
    • 커버리지 비교 결과를 PR에 댓글로 남겨 팀원들이 즉각적으로 확인 가능.

이외에도 테스트 코드를 작성했는지를 알려주거나, slack과 연동하면 커버리지 감소 메시지를 줄 수도 있다.


코드 품질 관리는 단순히 테스트 코드를 작성을 넘어, 테스트 코드를 지속적으로 유지하고 개선하는 과정이 필요하다.
그래서 이처럼 자동화된 커버리지 검증은 이 과정을 효율적으로 관리하는 데 도움을 주며, 팀의 코드베이스를 건강하게 유지할 수 있는 방법이다.

이번 시리즈에서 다룬 방법이 실무에 있어 조금이나마 도움이 되었으면 좋겠다 🙂



반응형

댓글