Spring에 Jacoco 적용해보기

2024. 4. 29. 21:07스프링 (Spring)/스프링 팁 (Spring Tip)

 

jacoco는 코드 커버리지를 보여주는 라이브러리입니다.

물론 커버리지가 높다고해서 100% 안정성을 보장해주지 않지만, 커버리지가 낮으면 안정성도 낮은 건 맞습니다.

테스트를 가시적으로 보지 않으면, 놓치는 것도 있으니 이를 보완할 겸 사용하는 걸 추천합니다.

 

GitHub - jacoco/jacoco: :microscope: Java Code Coverage Library

:microscope: Java Code Coverage Library. Contribute to jacoco/jacoco development by creating an account on GitHub.

github.com

 


jacoco Plugin

 

기본적으로 The JaCoCo Plugin - Gradle 문서를 참고하면 됩니다.

 

The JaCoCo Plugin

The JaCoCo plugin adds the following dependency configurations: Table 2. JaCoCo plugin - dependency configurations Name Meaning jacocoAnt The JaCoCo Ant library used for running the JacocoReport and JacocoCoverageVerification tasks. jacocoAgent The JaCoCo

docs.gradle.org

 

먼저 build.gradle.kts 을 열어서 jacoco 플러그인을 추가합니다.

plugins {
    ...
    jacoco
}

 

그리고 gradle reload 를 수행합니다.

눌러줘야 build.gradle.kts에서 jacoco를 사용할 수 있다

 

 

 

 

 

 


jacoco Settings

 

여전히 build.gradle.kts 에서 작성하시면 됩니다.

Test

Spring 3.* 버전을 사용하므로 JUnit에 대해 기본 설정이 되어 있긴하지만, 

없을 때를 대비해 useJUnitPlatform() 을 추가한 설정 그대로 가져왔습니다.

tasks.withType<Test> {
    useJUnitPlatform()
}
tasks.test {
    finalizedBy(tasks.jacocoTestReport)
}

 

이렇게 만들면 test 태스크가 끝나고 jacocoTestReport 태스크가 이어서 수행됩니다.

 

 

JacocoReport

스캔 대상에서 특정 디렉터리나 파일을 제외하려면 아래와 같이 exclude 함수를 이용합니다.

tasks.withType<JacocoReport> {
    afterEvaluate {
        classDirectories.setFrom(classDirectories.files.map {
            fileTree(it) {
                exclude("**/infra/**")
            }
        })
    }
}

 

여담으로 제가 사용한 설정은 다음과 같습니다.

tasks.withType<JacocoReport> {
    afterEvaluate {
        classDirectories.setFrom(classDirectories.files.map {
            fileTree(it) {
                exclude(
                    "**/*Application*",
                    "**/infra/configuration/**/*Configuration.class",
                    "**/infra/dao/**",
                    "**/app/controller/**/*Controller.class",
                    "**/app/controller/**/*Dto.class",
                    "**/app/controller/**/*Dto?Companion*",
                    "**/domain/service/**/*Vo.class"
                )
            }
        })
    }
}

 

다음으로 jacocoTaskReport 태스크를 설정합니다.

tasks.jacocoTestReport {
    dependsOn(tasks.test)
    reports {
        xml.required = false
        csv.required = false
        html.outputLocation = layout.buildDirectory.dir("jacocoHtml")
    }
    finalizedBy(tasks.jacocoTestCoverageVerification)
}

 

xml, csv는 무시하고 html 만 생성하도록 설정했습니다.

이렇게 만들면 jacocoHtml 디렉터리에 리포트(index.html)가 생성됩니다.

 

 

필터 예시 참고

 

Filter JaCoCo coverage reports with Gradle

Problem: I have a project with jacoco and I want to be able to filter certain classes and/or packages. Related Documentation: I have read the following documentation: Official jacoco site: http://www.

stackoverflow.com

 

패턴 사용법 참고 

 

PatternFilterable

A PatternFilterable represents some file container which Ant-style include and exclude patterns or specs can be applied to. Patterns may include: '*' to match any number of characters '?' to match any single character '**' to match any number of directorie

docs.gradle.org

 

 

JacocoTestCoverageVerification

코드 커버리지가 정한 비율을 넘는 지 추가로 검사할 수 있습니다.

tasks.jacocoTestCoverageVerification {
    dependsOn(tasks.jacocoTestReport)
    violationRules {
        rule {
            limit {
                minimum = "0.8".toBigDecimal()
            }
        }
    }
}​

 

이렇게 설정하면 커버리지가 80%를 넘어야 테스트가 통과합니다.

2024. 05. 02 : 여기도 exclude를 써야 했네요. 

도 모르고 커버리지 통과가 왜 안 되나 했네요...

 

JacocoReport와 공용으로 사용할 것이기 때문에 변수로 만들어두고

val coverageExcludePaths = listOf(
    "**/*Application*",
    "**/infra/configuration/**/*Configuration.class",
    "**/infra/dao/**",
    "**/app/controller/**/*Controller.class",
    "**/app/controller/**/*Dto.class",
    "**/app/controller/**/*Dto?Companion*",
    "**/domain/service/**/*Vo.class",
    "**/domain/base/**/*Vo.class"
)

 

아래처럼 JacocoCoverageVerification에 afterEvaluate를 추가했습니다.

tasks.withType<JacocoCoverageVerification> {
    afterEvaluate {
        classDirectories.setFrom(classDirectories.files.map {
            fileTree(it) {
                exclude(coverageExcludePaths)
            }
        })
    }
}

 

성공!

 

 

 

 


jacoco Report 

 

리포트를 보려면 index.html 를 실행하면 됩니다.

 

아직 테스트 코드를 안 짜서 개판이네요.

 

해당 결과를 예쁘게 보여주는 뷰어로는 sonarqube가 있습니다. 실무에서 유용하게 잘 써온 건데

지금 막상 찾아보니 개인이 쓰려면 연간 20만원 정도 줘야하는군요... 

마땅한 뷰어 프로젝트도 없는 것 같긴한데, 많이 아쉽네요. 시간이 남으면 ui나 개발해봐야 겠네요.

 

 

 

 


Miscellaneous Efforts

 

적용하면서 잡다하게 알아낸 것들을 소개해보겠습니다.

 

jacoco.excludes

처음에는 스캔 대상을 제외하는 걸로 이해했는데, 사실 아니었고

tasks.test {
    finalizedBy(tasks.jacocoTestReport)
    jacoco {
        excludes += "**/*ImplTest.class"
    }
}

 

와 같이 test 디렉터리에 위치한 테스트 코드를 제외하기 위함이었습니다.

아니 설명을 왜 이렇게 함...

이것도 모르고 괜히 왜 안되나 찾아보았네요...

 

 

jacocoTestReport.doFirst, doLast

tasks.withType<JacocoReport>

 

스캔 제외 대상을 위 블록에 작성했었는데,

태스크에서 설정하는 것과 withType에서 설정하는 것에 대해 차이를 몰랐습니다.

 

따라서 아래처럼 doFirst, doLast로 스캔 대상에서 제외하는 로직을 넣었으나 

tasks.jacocoTestReport {
    ...
    
    doFirst { // 또는 doLast
        classDirectories.setFrom(classDirectories.files.map {
            fileTree(it) {
                exclude("**/infra/**")
            }
        })
    }
}​

 

Execution failed for task ':jacocoTestReport'.
> The value for this file collection is final and cannot be changed.

 

와 같이 immutable 하기 때문에 classDirectories를 건드릴 수 없었네요.

 

조금 더 파보고 싶어서 찾아가봤는데

    public void setFrom(Iterable<?> path) {
        this.assertMutable();
        this.value = this.value.setFrom(...);
    }

 

에서 assertMutable()을 따라가고,

    private void assertMutable() {
        if (this.state == DefaultConfigurableFileCollection.State.Final && this.disallowChanges) {
            throw new IllegalStateException("The value for " + this.displayNameForThisCollection() + " is final and cannot be changed.");
        } else if (this.disallowChanges) {
            throw new IllegalStateException("The value for " + this.displayNameForThisCollection() + " cannot be changed.");
        } else if (this.state == DefaultConfigurableFileCollection.State.Final) {
            throw new IllegalStateException("The value for " + this.displayNameForThisCollection() + " is final and cannot be changed.");
        }
    }
public abstract class JacocoReportBase extends JacocoBase {
    private final ConfigurableFileCollection executionData = this.getProject().files(new Object[0]);
    private final ConfigurableFileCollection sourceDirectories = this.getProject().files(new Object[0]);
    private final ConfigurableFileCollection classDirectories = this.getProject().files(new Object[0]);
    private final ConfigurableFileCollection additionalClassDirs = this.getProject().files(new Object[0]);
    private final ConfigurableFileCollection additionalSourceDirs = this.getProject().files(new Object[0]);
    
    ...​

 

까지 와서 추정할 수 있는 건, 결국 doFirst, doLast 시점에는 해당 컬렉션의 상태가 Final로 바뀐다는 것을 짐작할 수 있었습니다.

그렇다면 afterEvaluate 까지는 mutable 하다는 것인데, 이는 Plugin Lifecycle 과도 관계가 있겠네요.

이는 나중에 조금 더 공부해보면 좋을 것 같습니다.