본문 바로가기

프로젝트/디베이트 타이머

CI 과정에서 jacoco를 활용한 테스트 커버리지 확인하기

 

안녕하세요 브로콜리입니다.

 

프로젝트를 진행하며, CI 스크립트를 맡게 되었습니다. CI 과정에서는 작성한 테스트가 모두 통과하는지 검증해 품질이 보장된 코드가 merge되도록 하였습니다. 그러나, 단순히 테스트의 결과를 검증하는 것은 테스트 결과를 보장할 뿐입니다.

 

오히려, 코드가 통합되는 과정에서는 다음과 같은 질문이 중요할 수 있습니다.

- 내가 작성한 코드를 검증할만한 테스트를 작성하였는가?
- 팀의 전반적인 테스트 커버리지는 어느정도 되는가?

 

그리고 이렇게 테스트에 대한 커버리지 컨벤션을 확인할 수 있도록 도와주는 것이 바로 jacoco 라이브러리입니다.

 

오늘은 CI 과정에서 테스트 커버리지를 검증하기 위해 사용했던 jacoco의 설정부터 CI 스크립트에 적용한 과정까지를 기록으로 남겨두고자 합니다.

 


#1. jacoco가 무엇을 하는가?

 

jacoco는 다음과 같은 일들을 해줄 수 있습니다.

 

1) CI 과정에서 통합을 시도하는 브랜치의 전체적인 테스트 커버리지를 검증합니다.

2) 내가 작성한 코드 변경 내역의 테스트 커버리지를 검증합니다.

3) PR에 테스트 커버리지 리포트를 작성하여 보여줍니다.

 

예를 들어 PR 하나 당 다음과 같은 테스트 커버리지 리포트가 comment로 달리게 됩니다.

 

그럼 jacoco를 어떻게 활용하는지 알아보도록 하겠습니다.


#2. Basic - 기본 설정 방법

 

2-1) 의존성 추가

build.gradle 파일에 jacoco 의존성을 추가해줍니다.

plugins {
    id 'java'
    id 'jacoco' //jacoco 추가
    id 'org.springframework.boot' version '3.4.0'
    id 'io.spring.dependency-management' version '1.1.6'
}

jacoco {
    toolVersion = '0.8.9'
}

2-2) task 지정

다음으로 jacocoTestReport task를 명시해주어야 합니다.

tasks.named('test') {
    useJUnitPlatform()
    finalizedBy jacocoTestReport // 1)
}

jacocoTestReport {
    dependsOn test // 2)
    
    reports { 
        //3) 
        xml.required.set(true)
        csv.required.set(false)
        html.required.set(false)
    }
}

1) test를 실행한 이후에는 실패여부에 상관없이(finalizedBy) jacocoTestReport task를 실행함을 설정

2) depensOn을 통해 test task 이후에만 jacocoTestReport를 실행하겠다고 설정

3) report 발행을 위한 형식 지정 : xml 파일로만 report를 찍어내겠다고 설정

 

해당 설정을 완료한 이후 테스트를 돌려보면 기본 설정 path인 build/reports/jacoco/test/ 하위에 jacocoTestReport파일이 생겨난 것을 볼 수 있습니다.

 


2-3) CI 스크립트 작성

 

다음으로 CI 스크립트 작성 과정입니다. 먼저 report가 발행될 comment 작성을 위해 CI 스크립트 상에서 permissions.checks와 pull-requests의 write 권한을 설정할 필요가 있습니다.

name: dev-ci

on:
  pull_request:
    branches:
      - develop

permissions:
  contents: read
  checks: write
  pull-requests: write

 

다음으로 jacoco-report 스크립트를 사용하여 관련 변수들을 설정해주면 됩니다.

      - name: Report test Coverage to PR
        id: jacoco
        uses: madrapps/jacoco-report@v1.6.1
        if: always()
        with:
          title: 📝 Test Coverage Report
          paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 80
          min-coverage-changed-files: 80
          update-comment: true
          debug-mode: true

 

- if:always() : 앞선 step들의 실패여부와 상관없이 무조건 실행해야함을 의미합니다.

- title : 작성하게되는 PR comment의 제목입니다.

- paths : jacocoTestReport가 있는 경로를 지정합니다.

- token : 해당 편집권한이 있는지 token을 통해 검증합니다.

- min-coverage-all : 코드 전체의 테스트 커버리지 기준을 정합니다.

- min-coveerage-changed-files : PR에서 변경된 코드파일의 커버리지 기준을 정합니다.

- update-comment : 매번 PR Report를 발행하는 것이 아니라, 한번 작성된 comment를 update 하도록 합니다.

- debug -mode : test가 실패하면 debug 모드로 다시 돌린 결과를 발행합니다.

 

 

이외의 전반적인 CI 스크립트는 접은 글로 첨부하도록하겠습니다.

더보기
name: dev-ci

on:
  pull_request:
    branches:
      - develop

permissions:
  contents: read
  checks: write
  pull-requests: write

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    timeout-minutes: 3
    env:
      TEST_REPORT: true

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Grant Permission
        run: chmod +x ./gradlew

      - name: Clean And Test With Gradle
        run: ./gradlew clean test

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: ${{ github.workspace }}/build/test-results/**/*.xml

      - name: JUnit Report Action
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: ${{ github.workspace }}/build/test-results/**/*.xml

      - name: Report test Coverage to PR
        id: jacoco
        uses: madrapps/jacoco-report@v1.6.1
        if: always()
        with:
          title: 📝 Test Coverage Report
          paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 80
          min-coverage-changed-files: 80
          update-comment: true
          debug-mode: true

#3. Advanced - 조금 더 신경써서 설정하기

사실 여기까지만 해도 위에 명시했던 PR report 기능은 사용이 가능합니다. 그러나, 한걸음 더 나아가서 다음같은 설정을 해보도록 하겠습니다.

1) 특정 객체는 테스트 커버리지에서 제외하기
2) lombok이 만든 코드는 테스트 커버리지에서 제외하기
3) local 환경에서는 jacoco를 돌아가지 않게 하고, CI에서만 돌아가게 하기

 

 

1) 특정 객체는 테스트 커버리지에서 제외하기

테스트를 하다보면, 일관적으로 테스트 대상에 포함되지 않은 객체들이 생깁니다. 예를 들어 데이터 전송 객체인 DTO 와 같은 객체가 대표적인 예시입니다. 그렇다면 특정 객체들은 테스트 대상에 포함하지 않도록 설정할 수는 없는 걸까요?

 

jacocoTestReport의 exclude 설정을 통해 가능합니다.

jacocoTestReport {
    dependsOn test
    reports {
        xml.required.set(true)
        csv.required.set(false)
        html.required.set(false)
    }

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                    "com/debatetimer/**/dto/**"
            ])
        }))
    }
}

 

예를 들어 dto 디렉토리 내부에 있는 객체들은 커버리지 측정 대상에 포함하지 않도록 하려면 com/프로젝트명/**/dto/**로 경로를 명시할 수 있습니다. 실제로 위에 예시로 들었던 Report를 보면 XXXReqest , XXXResponse와 같은 DTO 객체는 커버리지 측정 대상에서 빠져있는 모습을 볼 수 있습니다.

 

DTO가 측정 대상에 없다.


2) Lombok 이 생성한 코드 측정 대상에서 제외하기

 

lombok은 어노테이션 기반으로 보일러 플레이트 코드를 생성해주는 강력한 라이브러리입니다. 그러나, 보일러 플레이트 코드라는 점에서 생성자, getter, setter등을 테스트할 필요가 있을까요? 당연히 테스트 커버리지에서 제외해주는 편이 합리적입니다.

 

그렇다면 lombok 코드를 제외하기 위해서는 어떻게 해야할까요?

 

jacoco는 0.8.2버전부터 이름에 Generated가 들어간 애너테이션이 붙어 있다면 테스트 대상에서 제외하는 기능을 지원하기 시작했습니다.  그리고 lombok에서는 생성코드에 @Generated 애너테이션을 붙일 수 있는 설정을 지원합니다.

 

이를 위해선 소스루트에서 lombok.config 파일을 생성하고

 

해당 config파일에 Generated 애너테이션을 붙이겠다는 설정을 true로 바꾸어주면 됩니다.

lombok.addLombokGeneratedAnnotation = true

 

이제 lombok 생성 코드를 테스트 대상에서 제외할 수 있게 되었습니다.

 

 

3) local 환경에서는 jacoco를 돌아가지 않게 하고, CI에서만 돌아가게 하기

이 정도 설정만을 하고 PR을 올렸었는데요. 팀원 중 한명인 커찬이 다음과 같은 리뷰를 남겼습니다.

 

굉장히 좋은 제안이라 생각했던 이유는 도메인이 커짐에 따라 test가 많아지며 한번 test를 돌릴 때 계속해서 report가 갱신되다보니, test를 돌리기까지의 시간이 늘어나는 경험을 했었기 때문입니다. 또한 애초에 jacoco를 도입하자고 주장했던 이유가 매 test마다 report를 확인하기 위해서가 아닌, CI 과정에서 검증된 코드를 통합하기 위함이었기 때문에 local 환경에서 report 발행이 필요하지 않다는데 동감했습니다.

 

이에 몇가지 설정을 찾아보다가 task에서 onlyIf라는 설정을 통해 특정 조건이 만족해야지만 task를 실행하도록 조건을 걸어줄 수 있음을 알게되었습니다.

 

이에 따라, TEST_REPORT가 true로 설정되어있을 때만 해당 task를 실행하도록 수정해주었습니다.

jacocoTestReport {
    dependsOn test
    reports {
        xml.required.set(true)
        csv.required.set(false)
        html.required.set(false)
    }

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                    "com/debatetimer/**/dto/**"
            ])
        }))
    }

    onlyIf {
        return System.getenv('TEST_REPORT') == 'true'
    }
}

 

현재 local에는 TEST_REPORT라는 환경변수가 없는 상태입니다. 따라서 null이 반환되며 onlyIf의 조건을 충족시키지 못해 task가 실행되지 않게 됩니다.

 

반대로 CI 스크립트에서는 TEST_REPORT 환경변수를 true로 설정해주었습니다.

name: dev-ci

on:
  pull_request:
    branches:
      - develop

permissions:
  contents: read
  checks: write
  pull-requests: write

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    timeout-minutes: 3
    env:
      TEST_REPORT: true

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Grant Permission
        run: chmod +x ./gradlew

      - name: Clean And Test With Gradle
        run: ./gradlew clean test

      - name: Publish Test Results
        uses: EnricoMi/publish-unit-test-result-action@v2
        if: always()
        with:
          files: ${{ github.workspace }}/build/test-results/**/*.xml

      - name: JUnit Report Action
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: ${{ github.workspace }}/build/test-results/**/*.xml

      - name: Report test Coverage to PR
        id: jacoco
        uses: madrapps/jacoco-report@v1.6.1
        if: always()
        with:
          title: 📝 Test Coverage Report
          paths: ${{ github.workspace }}/build/reports/jacoco/test/jacocoTestReport.xml
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 80
          min-coverage-changed-files: 80
          update-comment: true
          debug-mode: true

 

따라서 Test 를 실행하도록 ./gradlew clean test 를 실행했을 때 onlyIf 조건을 만족시키게 되어 CI 환경에서는 jacocoTestReport가 발행되도록 설정할 수 있었습니다.

 

실제로 로컬환경에서 이를 검증한 결과, 테스트가 모두 끝난 직후 jacocoTestReport로 task가 넘어가지 않고 test가 끝나는 모습을 볼 수 있었습니다.

 

 

반대로 CI 환경에서는 testReport를 정상적으로 발행하는 모습을 볼 수 있었습니다.

 


+) Java 23에서의 IlegalClassFormatException 해결방안

 

jacoco  test report가 JDK 내부 클래스를 계측하려는 시도를 하면 문제가 발생가능합니다.

특히 Java 23 에서는 내부 구조가 더 민감해져 ci 스크립트가 도는 과정에서 해당 문제가 더 쉽게 발생 가능합니다.

 

따라서 build.gradle에서 test job 설정에 excludes 설정을 통해 JDK, sun 파일 등의 제외할 파일 경로를 설정해주는 것이 좋습니다.

 

tasks.named('test') {
    useJUnitPlatform()
    finalizedBy jacocoTestReport

    jacoco {
       includes = ['com.devoops.*']
       excludes = ['jdk.*', 'sun.*']
    }
}

 

또한 jacoco 버전 0.8.13이상이어야 Java 23버전과 잘 호환되었다.

버전 업 이전에는 호환되지 않는 툴이 있다며 에러를 반환하는 모습을 볼 수 있다.

 

 

reference)

https://www.baeldung.com/jacoco-report-exclude