앤디 블로그
  • 모두
  • 개발 문화
  • 기술
  • 자바
  • 스프링
  • 인프라
  • 카프카
  • 데이터베이스
  • 컨퍼런스
책
짧은 글
이력서
  • 모두
  • 개발 문화
  • 기술
  • 자바
  • 스프링
  • 인프라
  • 카프카
  • 데이터베이스
  • 컨퍼런스
책
짧은 글
이력서
  • 테스트가 업무다 - 2 (CICD: kover, diff-cover)

    • 1. 테스트 커버리지 툴을 도입한 이유
      • 목표
    • 2. 테스트 커버리지 툴
      • Kover
      • diff-cover
    • 3. 도입 방법
      • Gradle에 Kover 추가
      • GitHub Actions와 통합
      • 결과물

테스트가 업무다 - 2 (CICD: kover, diff-cover)

2025년 8월 19일
  • 1. 테스트 커버리지 툴을 도입한 이유
    • 목표
  • 2. 테스트 커버리지 툴
    • Kover
    • diff-cover
  • 3. 도입 방법
    • Gradle에 Kover 추가
    • GitHub Actions와 통합
    • 결과물

1. 테스트 커버리지 툴을 도입한 이유

테스트 코드를 모두 작성한 이후, 테스트 문화를 정착하기 위해 (비록 거의 혼자 코드를 짜고 있지만) 테스트 결과와 커버리지를 PR 메시지로 생성하도록 구현했다. 처음에는 테스트 커버리지 목표를 설정하려고 했으나 현재 상황에서 숫자 자체는 무의미하다고 생각하여 목표는 따로 없이 측정만 하도록 했다.

목표

  • CI 와 커버리지 확인을 통합
  • 신규/변경 라인 커버리지(diff coverage)를 수치로 보여준다.
  • 전체 커버리지도 함께 보되, 머지 기준은 변경분에 둔다. (현재는 목표 설정 x)

2. 테스트 커버리지 툴

Kover

뭐 하는 툴인가

  • Kotlin/JVM 프로젝트의 커버리지를 뽑는 Gradle 플러그인이다.
  • 내부적으로 IntelliJ 커버리지 엔진을 쓰고, JaCoCo XML 포맷을 뱉는다. 그래서 다른 도구(예: diff-cover)와 바로 물린다.
  • Kotlin의 인라인 함수, data class 생성 코드, 디폴트 인자 등에서 라인 매칭이 JaCoCo보다 자연스러운 경우가 많다.

왜 Kover를 썼나 (장점)

  • Kotlin 친화적이다. 코틀린 특유의 생성 코드 때문에 커버리지 라인이 어색하게 잡히는 문제를 줄였다.
  • 설정이 간단하다. 플러그인만 추가하면 koverXmlReport, koverHtmlReport 같은 태스크가 바로 생긴다.
  • **필터(Exclude)**가 유연하다. 패키지/클래스/파일/어노테이션 기준으로 제외할 수 있어 잡음(예: config, DTO, 생성코드)을 정리하기 좋다.

설치/사용build.gradle.kts

plugins {
    kotlin("jvm") version "1.9.24"
    id("org.jetbrains.kotlinx.kover") version "0.8.3"
}

tasks.test { useJUnitPlatform() }

/**
 * 리포트 생성 (기본 경로 예)
 *  - XML:  build/reports/kover/report.xml
 *  - HTML: build/reports/kover/html/index.html
 */

자주 쓰는 태스크

# 테스트 + XML
./gradlew test koverXmlReport

# HTML 리포트까지
./gradlew test koverHtmlReport

# (루트에서) 멀티모듈 집계 XML/HTML
./gradlew :koverXmlReport :koverHtmlReport

필터링(노이즈 줄이기)

kover {
    filters {
        classes {
            excludes += listOf(
                "com.example.config.*",
                "com.example.generated.*",
                "*.ApplicationKt"
            )
        }
        // 필요하면 패키지/파일/어노테이션 단위도 가능
    }
}

팁: “커버리지 0% 찍히는 파일”은 대개 생성 코드거나 테스트에서 로드되지 않은 경로다. 필터에서 과감히 제외하거나, 테스트로 실제 사용 경로를 태우면 된다.

diff-cover

  • **JaCoCo XML 리포트 + git diff**를 비교해서 “이번 변경 라인들의 커버리지”(diff coverage)를 계산한다.

    • 출력 형식은 텍스트/ANSI/Markdown/HTML/JSON/XML을 지원한다. PR 코멘트/체크/아티팩트 등 용도에 맞춰 뽑을 수 있다.

    왜 diff-cover를 썼나 (장점)

    • 변경분 중심의 품질 관리가 된다. 전체 커버리지 수치가 높아도 새로 바꾼 코드가 비어 있으면 바로 잡아낸다.
    • 머지 기준으로 쓰기 좋다. --fail-under 80처럼 임계치를 걸어 체크 실패 → 머지 차단 흐름을 만들 수 있다.
  • 도입이 빠르다. Kover가 내는 XML만 있으면 바로 적용된다. 빌드 파이프라인에 거의 영향 없다.

    • 출력 포맷이 풍부해서 PR 코멘트/HTML 리포트/머신 파싱(JSON)까지 한 번에 해결한다.

Kover + diff-cover 시너지

  • Kover는 정확한 Kotlin 라인 매핑 + JaCoCo XML을 제공하고, diff-cover는 그 XML로 변경분 커버리지를 계산한다.
  • 둘을 엮으면 **“전체 커버리지(헬스 체크)” + “변경분 커버리지(머지 기준)”**를 동시에 만족하는 파이프라인을 아주 간단히 만든다.
  • 특히 코틀린 프로젝트에서 **불필요한 라인(생성 코드)**을 Kover 필터로 빼고, 나머지를 diff-cover로 집요하게 본다. 리뷰 스트레스가 줄고, 실패도 **“정말 필요한 실패”**만 난다.

3. 도입 방법

Gradle에 Kover 추가

plugins {
    ...
    
    id("org.jetbrains.kotlinx.kover") version "0.9.1" // Kover 플러그인
}

tasks.test {
    useJUnitPlatform()
}


해당 plugin 을 추가하면 ./gradlew koverXmlReport 명령어를 사용할 수 있다. build/reports/kover/ 위치에 report.xml 을 생성해준다. 해당 리포트는 전체 테스트에 대한 커버리지이다.

GitHub Actions와 통합

목표는 PR에 스티키 코멘트 1개로 전체 커버리지 + 변경분 커버리지를 남기는 것이다.

.github/workflows/diff-coverage.yml
name: Diff Coverage (Kover + diff-cover)

on:
  pull_request:
    branches: [ main ]

permissions:
  contents: read
  pull-requests: write

jobs:
  diff-coverage:
    runs-on: ubuntu-latest
    env:
      THRESHOLD: "80"
      COVER_XML: build/reports/kover/report.xml

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # JDK + Gradle 캐시
      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: gradle

      # 프로젝트 .gradle 캐시(증분 빌드 가속)
      - name: Cache project .gradle
        uses: actions/cache@v3
        with:
          path: .gradle
          key: gradle-project-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: gradle-project-${{ runner.os }}-

      - name: Build & Test (Kover XML)
        run: ./gradlew --no-daemon test koverXmlReport

      - name: Verify coverage XML exists
        run: |
          test -f "${{ env.COVER_XML }}" || {
            echo "Coverage XML not found at ${{ env.COVER_XML }}"; exit 1;
          }

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.x"

      - name: Install diff-cover
        run: pip install diff-cover

      # 로그에 요약 + 임계치 체크(미달 시 실패)
      - name: Diff coverage (fail-under + log)
        run: |
          diff-cover "${{ env.COVER_XML }}" \
            --compare-branch origin/${{ github.base_ref || 'main' }} \
            --src-roots src/main/kotlin src/test/kotlin \
            --fail-under "${{ env.THRESHOLD }}" \
            --format ansi:-

      # HTML/Markdown 리포트 생성
      - name: Diff coverage (reports)
        run: |
          diff-cover "${{ env.COVER_XML }}" \
            --compare-branch origin/${{ github.base_ref || 'main' }} \
            --src-roots src/main/kotlin src/test/kotlin \
            --format html:diff-cover.html,markdown:diff-cover.md

      # 전체/변경 파일 커버리지 수치를 추출(코멘트는 만들지 않음)
      - name: JaCoCo coverage summary (no comment)
        id: jacoco
        uses: madrapps/jacoco-report@v1.7.2
        with:
          paths: ${{ github.workspace }}/${{ env.COVER_XML }}
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 0
          min-coverage-changed-files: 0
          comment-type: "none"

      # diff-cover.md -> 테이블 변환 + summary 덧붙여 최종 코멘트 생성
      - name: Merge coverage reports (build pr-comment.md)
        run: |
          echo "# 📊 Coverage Report" > pr-comment.md
          echo "" >> pr-comment.md
          echo "- Overall coverage: **${{ steps.jacoco.outputs['coverage-overall'] }}**" >> pr-comment.md
          echo "- Changed files coverage: **${{ steps.jacoco.outputs['coverage-changed-files'] }}**" >> pr-comment.md
          echo "" >> pr-comment.md
          echo "### 🔍 Diff Coverage" >> pr-comment.md
          echo "" >> pr-comment.md
          echo "|Files|Diff Coverage (%)|Missing lines|" >> pr-comment.md
          echo "|-----|-----------------|--------------|" >> pr-comment.md

          # 3번째 줄부터 읽고, 빈 줄은 무시, '## Summary' 만나면 종료
          tail -n +3 diff-cover.md | while read -r line; do
            [[ -z "$line" ]] && continue
            [[ "$line" == "## Summary"* ]] && break

            file=$(echo "$line"    | sed -E 's/^- ([^(]+) .*/\1/')
            percent=$(echo "$line" | sed -E 's/.*\(([0-9.]+%)\).*/\1/')
            missing=$(echo "$line" | grep -o 'Missing lines.*' | sed -E 's/Missing lines //')
            [[ -z "$missing" ]] && missing=" "

            # 경로 앞 6개 컴포넌트 제거 (src/main/kotlin/... 기준)
            short_file=$(echo "$file" | cut -d'/' -f7-)

            echo "|$short_file|$percent|$missing|" >> pr-comment.md
          done

          echo "" >> pr-comment.md
          # 원본의 Summary 블록 추가
          awk '/^## Summary/{flag=1} flag' diff-cover.md >> pr-comment.md

      # 스티키 코멘트로 PR에 1개만 유지
      - name: Post PR comment (sticky)
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          path: pr-comment.md

      # HTML 리포트는 아티팩트로 업로드(다운로드해서 열어보기)
      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        with:
          name: diff-cover-report
          path: diff-cover.html
          if-no-files-found: error
          retention-days: 14

결과물

  1. Test Results : 테스트 보고서다. 실패한다면 git actions 의 summary 에 실패 로그가 추가된다.
  2. Test Coverage Report : 커밋된 내용의 테스트 커버리지 보고서

![screencapture-github-hobeen-kim-kovertest-pull-6-2025-08-25-15_19_17 (1)](/images/2025-08-20-test2/screencapture-github-hobeen-kim-kovertest-pull-6-2025-08-25-15_19_17 (1).png) )