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.vue
는tests/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.yml
은 PR이 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.ts
는 Git 브랜치 간 변경된 파일 목록을 추출하기 위한 스크립트이다. (전체 코드보기)
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. 마치며…
이번 시간에는 테스트 코드를 관리하는 법 시리즈의 두 번째로, 커버리지 감소를 검사하는 법을 알아보았다.
프로세스를 요약하면 다음과 같다:
- GitHub Actions 설정:
- PR이 approve된 경우에만 커버리지를 비교하도록 워크플로우를 구성.
develop
브랜치를 기준으로 현재 브랜치의 커버리지와 비교.
- 커버리지 비교 스크립트:
compare-all-coverage.ts
로 수정된 파일의 커버리지를 분석.- 변경된 파일 목록 추출(
git-file-tracker.ts
)과 메트릭 비교로 커버리지 차이를 리포트 생성.
- 자동화의 결과:
- 커버리지가 감소한 경우 merge를 차단해 품질 저하 방지.
- 커버리지 비교 결과를 PR에 댓글로 남겨 팀원들이 즉각적으로 확인 가능.
이외에도 테스트 코드를 작성했는지를 알려주거나, slack과 연동하면 커버리지 감소 메시지를 줄 수도 있다.
코드 품질 관리는 단순히 테스트 코드를 작성을 넘어, 테스트 코드를 지속적으로 유지하고 개선하는 과정이 필요하다.
그래서 이처럼 자동화된 커버리지 검증은 이 과정을 효율적으로 관리하는 데 도움을 주며, 팀의 코드베이스를 건강하게 유지할 수 있는 방법이다.
이번 시리즈에서 다룬 방법이 실무에 있어 조금이나마 도움이 되었으면 좋겠다 🙂
'개발 기술 > 개발 이야기' 카테고리의 다른 글
Vue 3 <script setup> 선언 순서를 자동 정렬하는 ESLint 룰 개발기 (0) | 2025.02.28 |
---|---|
테스트 코드를 관리하는 법 1, Pre-Push 단계에서 테스트 자동화하기 (0) | 2025.01.01 |
아이콘 컴포넌트 렌더링 방식, 정말 좋을까? (with. 빌드 시간, FID, TBT 등 비교) (0) | 2024.08.11 |
[JS/CSS] corner smoothing을 구현하는 법(feat. 부드러운 둥근 모서리) (0) | 2024.06.18 |
이미지, 배경이미지의 지연 로드 구현 방법(with. Vue) (2) | 2024.06.10 |
댓글