[JaCoCo] Gradle 프로젝트에 JaCoCo 설정하기

by 스뎅(thDeng) on

이 글은 회사 기술블로그에 작성한 내용입니다.

JaCoCo는 Java 코드의 커버리지를 체크하는 라이브러리입니다. 테스트코드를 돌리고 그 커버리지 결과를 눈으로 보기 좋도록 html이나 xml, csv 같은 리포트로 생성합니다. 그리고 테스트 결과가 내가 설정한 커버리지 기준을 만족하는지 확인하는 기능도 있습니다. 저희팀 같은 경우는 프로젝트의 커버리지가 올라갈 때 마다 이 기준을 조금씩 올려가고 있고, 이 커버리지 기준을 만족시키지 못 하면 배포를 하지 못 하게 하고 있습니다. 브랜치 커버리지가 100%인 프로젝트도 있습니다.

여기서는 Java와 Kotlin 코드가 섞인 Gradle 프로젝트를 커버리지 체크하는 JaCoCo 설정을 살펴 보겠습니다. 이전 버전 JaCoCo 플러그인 같은 경우는 Java와 Kotlin 등 여러 언어의 소스가 섞여 있을 때는 소스 경로를 모두 체크하도록 설정해 줘야 했던 것 같지만, 지금(Gradle 6.0.1, JaCoCo 0.8.5)은 큰 설정 없이 사용할 수 있습니다. 이 글에서는 두 언어의 코드가 함께 체크되는 것을 볼 것입니다.

전체 샘플 코드는 jacoco-on-gradle-sample에서 확인할 수 있습니다. 본 글에 사용된 코드는 Groovy DSL로 작성된 build.gradle 내용을 사용하고, 각 코드에 Kotlin DSL로 작성된 gist 링크를 달아둡니다.

JaCoCo 플러그인 추가

Gradle 설정에 JaCoCo 플러그인을 추가하고, 플러그인 설정을 합니다. reportsDir로 테스트 결과 리포트를 저장할 경로를 바꿀 수 있습니다. [ Kotlin DSL ]

plugins {
  jacoco
}

jacoco {
  // JaCoCo 버전
  toolVersion = "0.8.5"

//  테스트결과 리포트를 저장할 경로 변경
//  default는 "${project.reporting.baseDir}/jacoco"
//  reportsDir = file("$buildDir/customJacocoReportDir")
}

Gradle task 설정 - 테스트 리포트 저장과 커버리지 체크

JaCoCo Gradle 플러그인에는 jacocoTestReportjacocoTestCoverageVerification task가 있습니다.

[ Kotlin DSL ]

tasks.jacocoTestReport {
  reports {
    // 원하는 리포트를 켜고 끌 수 있습니다.
    html.isEnabled = true
    xml.isEnabled = false
    csv.isEnabled = false

//  각 리포트 타입 마다 리포트 저장 경로를 설정할 수 있습니다.
//  html.destination = file("$buildDir/jacocoHtml")
//  xml.destination = file("$buildDir/jacoco.xml")
  }
}

tasks.jacocoTestCoverageVerification {
  violationRules {
    rule {
      element = "CLASS"

      limit {
        counter = "BRANCH"
        value = "COVEREDRATIO"
        minimum = "0.90".toBigDecimal()
      }
    }
  }
}

JaCoCo 플러그인은 자동으로 모든 Test 타입의 task에 JacocoTaskExtension을 추가하고, test task에서 그 설정을 변경할 수 있게 합니다. (JaCoCo specific task configuration) 그래서 아래 설정처럼 test task에서 extension을 설정할 수 있습니다. 아래 설정은 커버리지 결과 데이터를 저장할 경로를 변경하는 것이고, unit test와 integration test 등을 분리할 때 사용하면 유용할 수 있습니다. [ Kotlin DSL ]

tasks.test {
  extensions.configure(JacocoTaskExtension::class) {
    destinationFile = file("$buildDir/jacoco/jacoco.exec")
  }
}

아래 코드는 플러그인에서 test task에 default로 설정된 값들입니다. 이 값들은 위의 destinationFile처럼 오버라이드 할 수 있습니다. (JacocoTaskExtension 참고) [ Kotlin DSL ]

tasks.getByName<Test>("test") {
  extensions.configure(JacocoTaskExtension::class) {
    isEnabled = true
    destinationFile = file("$buildDir/jacoco/$name.exec")
    includes = listOf()
    excludes = listOf()
    excludeClassLoaders = listOf()
    isIncludeNoLocationClasses = false
    sessionId = "<auto-generated value>"
    isDumpOnExit = true
    classDumpDir = null
    output = JacocoTaskExtension.Output.FILE
    address = "localhost"
    port = 6300
    isJmx = false
  }
}

코드 준비와 테스트 실행

JaCoCo 테스트를 돌려볼 소스 코드와 테스트 코드를 준비합니다. 여기서는 JUnit5로 테스트를 작생했기 때문에 테스트 시 JUnit이 함께 동작할 수 있도록 다음 설정을 해줍니다. [ Kotlin DSL ]

tasks.withType<Test> {
  useJUnitPlatform()
}

위 설정은 test task에서는 JUnit을 사용한다고 Gradle에 알려주는 것입니다. 이 설정을 주지 않으면 아래처럼 jacocoTestReport task와 jacocoTestCoverageVerification task를 스킵하게 됩니다. 실제로 테스트 자체가 돌지 않게 됩니다.

$ ./gradlew --console verbose test jacocoTestReport jacocoTestCoverageVerification

.. (생략) ..

> Task :jacocoTestReport SKIPPED
Skipping task ':jacocoTestReport' as task onlyIf is false.
:jacocoTestReport (Thread[Execution worker for ':' Thread 5,5,main]) completed. Took 0.002 secs.
:jacocoTestCoverageVerification (Thread[Execution worker for ':' Thread 7,5,main]) started.

> Task :jacocoTestCoverageVerification SKIPPED
Skipping task ':jacocoTestCoverageVerification' as task onlyIf is false.
:jacocoTestCoverageVerification (Thread[Execution worker for ':' Thread 7,5,main]) completed. Took 0.001 secs.

BUILD SUCCESSFUL in 4s
7 actionable tasks: 7 executed

그려면 소스코드와 테스트코드를 준비합니다.

// src/main/java/kr/leocat/test/kotlinjacocosample/JavaFoo.java
package kr.leocat.test.kotlinjacocosample;

public class JavaFoo {

    public String hello(String name) {
        switch (name) {
            case "펭":
                return "하";
            case "hello":
                return "world";
            default:
                return "no one";
        }
    }

    public void callMe() {
        System.out.println("Please, call me");
    }

}
// src/test/java/kr/leocat/test/kotlinjacocosample/JavaFooTest.java
package kr.leocat.test.kotlinjacocosample;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class JavaFooTest {

    private JavaFoo javaFoo = new JavaFoo();

    @Test
    public void partiallyCoveredHelloMethodTest() {
        String actual = javaFoo.hello("펭");
        assertEquals(actual, "하");
    }

}

위는 Java 코드이고 아래는 함께 테스트할 Kotlin 코드입니다.

// src/main/kotlin/kr/leocat/test/kotlinjacocosample/KotlinFoo.kt
package kr.leocat.test.kotlinjacocosample

class KotlinFoo {

    fun hello(name: String): String = when {
        name == "Hello" ->  "world"
        name == "펭" ->  "하"
        name.length > 5 -> "TOO LONG"
        else -> "no one"
    }

    fun callMe() {
        println("Please, call me")
    }

}
// src/test/kotlin/kr/leocat/test/kotlinjacocosample/KotlinFooTest.kt
package kr.leocat.test.kotlinjacocosample

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

internal class KotlinFooTest {

    private val kotlinFoo = KotlinFoo()

    @Test
    fun `partially covered hello method test`() {
        val actual = kotlinFoo.hello("펭")
        assertEquals(actual, "하")
    }

}

이제 기본 설정도 끝났고 코드도 준비 됐으니, 리포트를 생성(jacocoTestReport)하고 커버리지 체크(jacocoTestCoverageVerification)를 실행해 봅니다. 어떤 task가 실행되는지 보기 위해 --console verbose 옵션을 추가했습니다.

$ ./gradlew --console verbose test jacocoTestReport jacocoTestCoverageVerification
> Task :compileKotlin
> Task :compileJava
> Task :processResources
> Task :classes
> Task :compileTestKotlin
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :jacocoTestReport

> Task :jacocoTestCoverageVerification FAILED
[ant:jacocoReport] Rule violated for class kr.leocat.test.kotlinjacocosample.JavaFoo: branches covered ratio is 0.33, but expected minimum is 0.90
[ant:jacocoReport] Rule violated for class kr.leocat.test.kotlinjacocosample.KotlinFoo: branches covered ratio is 0.33, but expected minimum is 0.90

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':jacocoTestCoverageVerification'.
> Rule violated for class kr.leocat.test.kotlinjacocosample.JavaFoo: branches covered ratio is 0.33, but expected minimum is 0.90
  Rule violated for class kr.leocat.test.kotlinjacocosample.KotlinFoo: branches covered ratio is 0.33, but expected minimum is 0.90

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 2s
8 actionable tasks: 8 executed

Gradle 빌드가 실패했습니다. 네, 정상이예요. 커버리지 체크 설정(minimum = 0.90)을 실패하도록 높게 설정했기 때문입니다. jacocoTestReport task는 정상적으로 실행된 것을 볼 수 있고, build/reports/jacoco/test/html/index.html 경로에 가보면 아래 스크린샷과 같은 리포트가 생성된 것을 확인할 수 있습니다. 하지만, jacocoTestCoverageVerification task는 위에서 설정한 커버리지 체크 기준을 넘지 못 했기 때문에 실패한 것을 볼 수 있습니다. 브랜치 커버리지가 설정한 90%를 넘어야 하는데, 2개 클래스 모두 33% 밖에 되지 않습니다. 위의 실행 결과에서 0.33으로 표시된 부분입니다. (자세한 커버리지 설정은 이 글 맨 아래에서 다시 설명합니다.)

만들어진 html 리포트를 브라우저로 열면 다음과 같이 각 커버리지 항목 마다 총 개수와 놓친 개수를 표시해 줍니다.

JaCoCo package report

JaCoCo class report

JaCoCo method report

코드 파일에서는 커버가 된 라인은 초록색, 놓친 부분은 빨간색으로 표시해 줍니다. 노란색은 모든 조건이 아닌 일부만 테스트된 라인입니다. 브랜치 커버리지를 예를 들면 if문에서 true나 false 중 한 가지 조건만 테스트한 경우가 될 수 있습니다. name == "Hello"는 false만 테스트 됐고, name == "펭"은 true만 테스트 돼서 노란색으로 표시 됐습니다.

JaCoCo file report

여러 task를 함께 실행

매번 jacocoTestReport task와 jacocoTestCoverageVerification task를 지정해 주려면 귀찮으니 이 task들을 하나로 묶는 testCoverage라는 task를 만들어 봅니다. [ Kotlin DSL ]

val testCoverage by tasks.registering {
  group = "verification"
  description = "Runs the unit tests with coverage"

  dependsOn(":test",
            ":jacocoTestReport",
            ":jacocoTestCoverageVerification")

  tasks["jacocoTestReport"].mustRunAfter(tasks["test"])
  tasks["jacocoTestCoverageVerification"].mustRunAfter(tasks["jacocoTestReport"])
}

testCoverage task를 실행하면 testjacocoTestReport, jacocoTestCoverageVerification를 실행하도록 dependsOn으로 설정합니다. 그리고, 이 task들은 실행하는 순서가 정해져 있으니 mustRunAfter로 순서를 지정해 줍니다. 테스트(test)를 먼저 실행하고, 그 결과로 리포트를 생성(jacocoTestReport)하고, 커버리지가 원하는 기준 만큼 도달했는지 체크(jacocoTestCoverageVerification)해야 합니다.

순서를 지정하지 않으면 jacocoTestCoverageVerification task가 jacocoTestReport task가 먼저 실행될 수 있습니다. 이 때 커버리지를 통과하지 못 하면 jacocoTestCoverageVerification task에서 Gradle 빌드가 멈추게 되고 jacocoTestReport task가 실행되지 않습니다. 그러면 리포트 자체가 새로 생성되지 못 하고 이전의 리포트를 계속 보게 되는 경우가 생길 수 있습니다. 때문에 실행되는 순서를 맞춰 주는 것이 좋습니다.

test -> jacocoTestReport -> jacocoTestCoverageVerification

이 task의 groupverification으로 지정해 주면, 다른 Gradle 테스트 관련 task와 함께 그루핑 된 것을 볼 수 있습니다. (tasks로 확인할 수 있습니다.)

$ ./gradlew tasks
> Task :tasks

------------------------------------------------------------
Tasks runnable from root project
------------------------------------------------------------

... (생략) ...

Verification tasks
------------------
check - Runs all checks.
jacocoTestCoverageVerification - Verifies code coverage metrics based on specified rules for the test task.
jacocoTestReport - Generates code coverage report for the test task.
test - Runs the unit tests.
testCoverage - Runs the unit tests with coverage

Gradle tasks

준비가 다 됐으니 testCoverage task를 실행해 보면 원하는 순서대로 task가 실행되는 것을 볼 수 있습니다.

$ ./gradlew --console verbose testCoverage
> Task :compileKotlin
> Task :compileJava
> Task :processResources
> Task :classes
> Task :compileTestKotlin
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :jacocoTestReport
> Task :jacocoTestCoverageVerification
> Task :testCoverage

BUILD SUCCESSFUL in 3s
8 actionable tasks: 8 executed

test task 실행 시 JaCoCo task 실행하도록 설정

testCoverage task를 만들긴 했는데, (나는 기억력이 워낙 안 좋으니까..) 자꾸 까먹을 것만 같습니다. 그럴 때는 test task를 실행할 때 마다 자동으로 JaCoCo task 들이 실행되도록 finalizedBy로 설정해 줄 수 있습니다. [ Kotlin DSL ]

tasks.test {
  // ... (생략) ...

  finalizedBy("jacocoTestReport")
}

tasks.jacocoTestReport {
  // ... (생략) ...

  finalizedBy("jacocoTestCoverageVerification")
}

이제 test task가 실행될 때 마다 자동으로 jacocoTestReport task와 jacocoTestCoverageVerification task가 순서대로 실행됩니다. 테스트만 하려고 했는데 리포트 까지 생성돼서 불편할 수 있으니 이 설정은 개인취향인 것 같습니다.

커버리지 기준 설정

그럼 위에서 간단히 넘어간 jacocoTestCoverageVerification task의 상세 설정을 살펴 봅니다. 이 task는 설정해 둔 커버리지 기준을 만족하는 코드를 짰는지 확인할 수 있습니다. violationRules로 커버리지 기준을 설정하는 룰을 정의합니다. [ Kotlin DSL ]

tasks.jacocoTestCoverageVerification {
  violationRules {
    rule {
      // 'element'가 없으면 프로젝트의 전체 파일을 합친 값을 기준으로 합니다.
      limit {
        // 'counter'를 지정하지 않으면 default는 'INSTRUCTION'
        // 'value'를 지정하지 않으면 default는 'COVEREDRATIO'
        minimum = "0.30".toBigDecimal()
      }
    }

    // 여러 룰을 생성할 수 있습니다.
    rule {
      // 룰을 간단히 켜고 끌 수 있습니다.
      enabled = true

      // 룰을 체크할 단위는 클래스 단위
      element = "CLASS"

      // 브랜치 커버리지를 최소한 90% 만족시켜야 합니다.
      limit {
        counter = "BRANCH"
        value = "COVEREDRATIO"
        minimum = "0.90".toBigDecimal()
      }

      // 라인 커버리지를 최소한 80% 만족시켜야 합니다.
      limit {
        counter = "LINE"
        value = "COVEREDRATIO"
        minimum = "0.80".toBigDecimal()
      }

      // 빈 줄을 제외한 코드의 라인수를 최대 200라인으로 제한합니다.
      limit {
        counter = "LINE"
        value = "TOTALCOUNT"
        maximum = "200".toBigDecimal()
      }
    }
  }
}

커버리지 체크 기준이 되는 element는 다음 중에 하나를 선택할 수 있습니다. 위의 샘플처럼 CLASS를 선택하면 클래스 단위로 브랜치와 라인 커버리지를 체크합니다. (JacocoLimit ‘element’ 참고)

설정 가능한 counter는 아래와 같습니다. (JacocoLimit ‘counter’ 참고)

그리고 value는 아래 중에서 선택할 수 있습니다. (JacocoLimit ‘value’ 참고)

위와 같이 설정해서 테스트를 돌려보면 아래와 같은 실패 결과를 볼 수 있습니다. 브랜치 커버리지 90%를 원했지만 33% 밖에 되지 못 하고, 라인 커버리지는 42% 밖에 되지 못 해서 실패했습니다. 커버리지 수치는 BigDecimal을 강제합니다. 내가 지정한 유효자리수 까지만 표기가 되며, 그 자리수를 넘어가면 반올림 됩니다. 위의 샘플에서 설정한 라인 커버리지 80%를 0.80 대신 0.8로 쓰게 되면, 0.42가 아닌 0.4로 표시되는걸 볼 수 있습니다.

$ ./gradlew --console verbose test
> Task :compileKotlin
> Task :compileJava
> Task :processResources
> Task :classes
> Task :compileTestKotlin
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :jacocoTestReport

> Task :jacocoTestCoverageVerification FAILED
[ant:jacocoReport] Rule violated for class com.woowahan.thdeng.test.jacoco.KotlinFoo: branches covered ratio is 0.33, but expected minimum is 0.90
[ant:jacocoReport] Rule violated for class com.woowahan.thdeng.test.jacoco.JavaFoo: branches covered ratio is 0.33, but expected minimum is 0.90

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':jacocoTestCoverageVerification'.
> Rule violated for class com.woowahan.thdeng.test.jacoco.KotlinFoo: branches covered ratio is 0.33, but expected minimum is 0.90
  Rule violated for class com.woowahan.thdeng.test.jacoco.JavaFoo: branches covered ratio is 0.33, but expected minimum is 0.90

* Try:
Run with --stacktrace option to get the stack trace. Run with --info or --debug option to get more log output. Run with --scan to get full insights.

* Get more help at https://help.gradle.org

BUILD FAILED in 3s
8 actionable tasks: 8 executed

JaCoCo는 여러 룰을 위한하는 경우 처음 위반한 룰만 리포팅합니다. (Enforcing code coverage metrics 참고)

JaCoCo 테스트에서 제외하기

커버리지를 올리지 않아도 되는 코드들이 있습니다. 예를 들어, QueryDsl을 사용하면 자동으로 생성되는 구현체인 Q*.class 같은 파일이나, spring-batch 등의 배치 설정 파일들이 있습니다. 이런 파일들은 커버리지 체크에서 제외할 수 있습니다. excludes로 제외할 클래스명을 지정할 수 있고, 와일드카드(* 과 ?)를 사용할 수 있습니다. [ Kotlin DSL ]

tasks.jacocoTestCoverageVerification {
  violationRules {
    rule {
      element = "CLASS"

      limit {
        counter = "LINE"
        value = "TOTALCOUNT"
        maximum = "8".toBigDecimal()
      }

      // 커버리지 체크를 제외할 클래스들
      excludes = listOf(
//      "*.test.*",
        "*.Kotlin*"
      )
    }
  }
}

이 설정은 라인수가 8줄을 넘지 않아야 하는 커버리지 체크이고, 샘플에 있는 KotlinFoo 클래스를 커버리지 체크에서 제외해서 빌드가 정상적으로 성공합니다. excludes 부분을 제거하면 테스트가 실패하는 것을 볼 수 있습니다. 여기서 조심할 점은 경로가 아닌 패키지 + 클래스명 을 적어줘야 한다는 것입니다. 다른 프로젝트에서 쓰듯이 경로명으로 설정하면서 미친듯이 삽질을 했는데, 매뉴얼 다시 똑바로 읽고 클래스명으로 하니 잘 동작합니다.

List<String> excludes

List of class names that should be excluded from analysis. Names can use wildcard (* and ?). Defaults to an empty list.

뭘 하든 매뉴얼을 잘 읽자;;

긴 글 읽어 주셔서 고맙습니다.

참고

별도로 명시하지 않을 경우, 이 블로그의 포스트는 다음 라이선스에 따라 사용할 수 있습니다: Creative Commons License CC Attribution-NonCommercial-ShareAlike 4.0 International License