새소식

Spring

Spring - Jacoco 적용하기

  • -

 

공부한 내용을 정리하는 블로그와 관련 코드를 공유하는 Github이 있습니다.

 

코드 커버리지

 

코드 커버리지는 소프트웨어의 테스트 케이스가 얼마나 충족되었는지를 나타내는 지표 중 하나입니다. 테스트를 진행하였을 때 '코드 자체가 얼마나 실행되었느냐'는 것이고, 이는 수치를 통해 확인할 수 있습니다. 코드 커버리지의 측정 기준은 크게 구문, 조건, 결정 3가지로 나뉩니다. 가장 대표적으로 많이 사용되고 있는 측정 기준은 구문 입니다.

 

구문(Statement) = 라인(Line) 커버리지

 

코드 한 줄이 한 번이상 실행된다면 충족한다는 것을 기준으로 측정됩니다.

void foo (int x) {
    system.out("start line"); // 1번
    if (x > 0) { // 2번
        system.out("middle line"); // 3번
    }
    system.out("last line"); // 4번
}

 

위의 코드를 테스트한다고 가정해보겠습니다. x = -1 을 테스트 데이터로 사용할 경우, if 문의 조건을 통과하지 못하기 때문에 3번 코드는 실행되지 못합니다. 총 4개의 라인에서 1, 2, 4번의 라인만 실행되므로 구문 커버리지는 3 / 4 * 100 = 75(%) 가 됩니다.

 

조건(Condition)

 

모든 조건식의 내부 조건이 true/false을 가지게 되면 충족하는 것을 기준으로 측정됩니다.

void foo (int x, int y) {
    system.out("start line"); // 1번
    if (x > 0 && y < 0) { // 2번
        system.out("middle line"); // 3번
    }
    system.out("last line"); // 4번
}

 

내부 조건이라는 말이 혼동될 수 있는데 조건식 내부의 각각의 조건이라 생각하면 될 것 같습니다. 위 코드를 예시로 보면 모든 조건식으로는 2번 if 문이 있고, 그중 내부 조건은 조건식 내부의 x > 0, y < 0 을 말합니다. 위의 코드를 테스트한다고 가정해보겠습니다. 조건 커버리지를 만족하는 테스트 케이스로는 (x = 1, y = 1), (x = -1, y = -1) 이 있습니다. 이는 x > 0 내부 조건에 대해 true/false를 만족하고, y < 0 내부 조건에 대해 false/true를 만족합니다. 즉, 테스트 케이스 if 문의 조건에 대해 false만 반환합니다.

 

if 문의 조건을 통과하지 못하기 때문에 3번 코드는 실행되지 못합니다. 즉, 조건 커버리지는 각 조건에 대해 true/false가 나와 만족할지라도 구분 커버리지를 기준으로 봤을 때는 만족하지 못하는 상황이 발생합니다.

 

결정(Decision) = 브랜치(Branch) 커버리지

 

모든 조건식이 true/false을 가지게 되면 충족됩니다.

void foo (int x, int y) {
    system.out("start line"); // 1번
    if (x > 0 && y < 0) { // 2번
        system.out("middle line"); // 3번
    }
    system.out("last line"); // 4번
}

 

여기서 모든 조건식이란 내부 조건이 아니라 if문의 포괄적인 조건식이라고 생각하면 됩니다. if 문의 조건에 대해 true/false 모두 가질 수 있는 테스트 케이스로는 (x = 1, y = -1), (x = -1, y = 1) 이 있습니다. 첫 번째 테스트 데이터는 x > 0 과 y < 0 모두 true이기 때문에 if 문의 조건에 대해 true를 반환합니다. 두 번째 테스트 데이터는 x < 0 에서 이미 false이기 때문에 if 문의 조건에 대해 false를 반환합니다. 모든 조건식에 대해 true와 false를 반환하므로 결정 커버리지를 충족합니다.

 

자바 코드 커버리지 Jacoco

 

코드 커버리지 분석 도구는 앞서 설명한 코드 커버리지를 개발자가 직접 확인하지 않고 분석할 수 있도록 도와주는 도구입니다. 자바 코드 커버리지 분석 도구는 여러 가지가 존재하는데, 대표적으로 Cobertura, Jacoco, Clover 등이 있습니다. Jacoco를 제외한 나머지는 레퍼런스가 너무 부족하고 Jacoco는 사용 방법이 간단하고 설정한 커버리지를 만족하는지 여부를 확인할 수 있기 때문에 대부분 Jacoco를 선택합니다.

 

build.gradle 설정하기

플러그인 추가

plugins {
  id 'jacoco'
}

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

//  테스트결과 리포트를 저장할 경로 변경하는 방법
//  default는 "$/jacoco"
//  reportsDir = file("$buildDir/customJacocoReportDir")
}

 

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

 

test {
  jacoco {
    destinationFile = file("$buildDir/jacoco/jacoco.exec")
  }
}

 

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

 

test {
  jacoco {
    enabled = true
    destinationFile = file("$buildDir/jacoco/$.exec")
    includes = []
    excludes = []
    excludeClassLoaders = []
    includeNoLocationClasses = false
    sessionId = "<auto-generated value>"
    dumpOnExit = true
    classDumpDir = null
    output = JacocoTaskExtension.Output.FILE
    address = "localhost"
    port = 6300
    jmx = false
  }
}

 

Task 설정

 

JaCoCo Gradle 플러그인에는 jacocoTestReport와 jacocoTestCoverageVerification task가 있습니다.

 

jacocoTestReport

 

  • 바이너리 커버리지 결과를 사람이 읽기 좋은 형태의 리포트로 저장합니다.
  • html 파일로 생성해 사람이 쉽게 눈으로 확인할 수도 있고, SonarQube 등으로 연동하기 위해 xml, csv 같은 형태로도 리포트를 생성할 수 있습니다.
jacocoTestReport {
  reports {
    // 원하는 리포트를 켜고 끌 수 있습니다.
    html.enabled true
    xml.enabled false
    csv.enabled false

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

 

jacocoTestCoverageVerification

 

  • 내가 원하는 커버리지 기준을 만족하는지 확인해 주는 task입니다.
  • 예를 들어, 브랜치 커버리지를 최소한 80% 이상으로 유지하고 싶다면, 이 task에 설정하면 됩니다.
  • test task처럼 Gradle 빌드의 성공/실패로 결과를 보여줍니다
jacocoTestCoverageVerification {
    violationRules {
        rule {
            enabled = true // 활성화
            element = 'CLASS' // 클래스 단위로 커버리지 체크
            // includes = []                

            // 라인 커버리지 제한을 80%로 설정
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

            // 브랜치 커버리지 제한을 80%로 설정
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

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


            excludes = []
        }

        // 여러 개의 rule 정의 가능
        rule {
            ...
        }
    }
}

 

violationRules는 커버리지 기준을 설정하는 룰을 정의하는 곳으로 그 안에 여러 개의 rule를 생성해서 정의할 수 있습니다. 여러 가지 속성 값들이 존재하는데 하나씩 알아보겠습니다.

  • enable
    • 해당 rule의 활성화 여부를 나타냅니다.
    • Default값은 true
  • element : 커버리지를 체크할 기준(단위)를 정할 수 있으며, 총 6개의 기준이 존재합니다.
    • Default값은 BUNDLE
    • BUNDLE : 패키지 번들(프로젝트 모든 파일을 합친 것)
    • CLASS : 클래스
    • GROUP : 논리적 번들 그룹
    • METHOD : 메서드
    • PACKAGE : 패키지
    • SOURCEFILE : 소스 파일
  • includes
    • rule의 적용 대상을 package 수준으로 정의할 수 있습니다.
    • Default 값은 전체 Package
  • Counter : limit 메서드를 통해서 지정할 수 있으며 커버리지 측정의 최소 단위를 말합니다.
    • Default값은 INSTRUCTION
    • LINE : 빈 줄을 제외한 실제 코드의 라인 수, 라인이 한 번이라도 실행되면 실행된 것으로 간주
    • BRANCH : 조건문 등의 분기 수
    • CLASS : 클래스 수, 내부 메서드가 한 번이라도 실행된다면 실행된 것으로 간주
    • COMPLEXITY : 복잡도
    • INSTRUCTION : Java 바이트코드 명령 수
    • METHOD : 메서드 수, 메서드가 한 번이라도 실행된다면 실행된 것으로 간주
  • Value : limit 메서드를 통해 지정할 수 있으며 측정한 커버리지를 어떠한 방식으로 보여줄 것인지를 말합니다.
    • Default 값은 COVEREDRATIO
    • COVEREDRATIO : 커버된 비율, 0부터 1사이의 숫자로 1이 100%
    • COVEREDCOUNT : 커버된 개수
    • MISSEDCOUNT : 커버되지 않은 개수
    • MISSEDRATIO : 커버되지 않은 비율, 0부터 1사이의 숫자로 1이 100%
    • TOTALCOUNT : 전체 개수
  • minimum : limit 메서드를 통해 지정할 수 있으며 counter 값을 value 에 맞게 표현했을 때 최솟값을 말합니다. 이 값을 통해 jacocoTestCoverageVerification 의 성공 여부가 결정됩니다.
    • Default값이 존재하지 않습니다.
    • 80%를 최소값으로 잡고 싶다면 0.80을 입력해야 합니다.
    • 0.8을 입력시 0.86도 내림되어 0.8로 입력됩니다.
  • excludes
    • 커버리지를 측정할 때 제외할 클래스를 지정할 수 있습니다.
    • 패키지 레벨의 경로로 지정하여야 하고 경로에는 * 와 ? 를 사용할 수 있습니다.
    • 주의할 점은 패키지+클래스명 을 적어줘야 합니다.

 

순서 결정하기

 

커버리지를 측정하기 위해서는 당연히 테스트가 진행된 이후에 Task가 동작해야 합니다. 하지만 플러그인은 test Task와의 의존성이 설정되어 있지 않기 때문에 직접 연결해줘야 합니다.

test {
    useJUnitPlatform()

    finalizedBy 'jacocoTestReport'
}


jacocoTestReport {

    ...

    finalizedBy 'jacocoTestCoverageVerification'
}

jacocoTestCoverageVerification {
    ...
}

 

이는 finalizedBy를 통해서 연결할 수 있습니다. 결과적으로 test -> jacocoTestReport -> jacocoTestCoverageVerification 순서로 Task를 실행하게 됩니다.

 

전체 코드

plugins {
    id 'org.springframework.boot' version '2.6.2'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    // jacoco
    id 'jacoco'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    runtimeOnly 'com.h2database:h2'
}

test {
    useJUnitPlatform()
    finalizedBy 'jacocoTestReport'
}


jacoco {
    toolVersion = '0.8.7'
}

jacocoTestReport {
    reports {
        html.enabled true
        xml.enabled false
        csv.enabled false
    }
    finalizedBy 'jacocoTestCoverageVerification'
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            enabled = true // 활성화
            element = 'CLASS' // 클래스 단위로 커버리지 체크
            // includes = []

            // 라인 커버리지 제한을 80%로 설정
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

            // 브랜치 커버리지 제한을 80%로 설정
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

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


            //excludes = []
        }

    }
}

 

테스트

 

테스트를 돌리고 나면 build/reports/jacoco/test/html 폴더 안에 index.html 파일이 있습니다.

 

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

 

코드 파일에서는 커버가 된 라인은 초록색, 놓친 부분은 빨간색으로 표시해 줍니다. 노란색은 모든 조건이 아닌 일부만 테스트된 라인입니다. 위에서 보면 if와 else if 문은 false가 나오고 True는 나오지 않았기 때문에 일부만 테스트되었다고 노란색으로 표시된 것입니다.

 

테스트 제외하기

 

lombok 라이브러리, Querydsl 라이브러리 등을 사용할 경우 굳이 테스트에 포함하지 않아도 되는 부산물들이 생기게 됩니다. 하나씩 제거해보겠습니다.

 

Querydsl Q도메인 제거하기

 

Querydsl로 생기는 Q도메인의 경우에는 QMember같이 앞에 2글자가 대문자로 표기됩니다. 이를 커버리지 측정에서 제외하겠습니다.

jacocoTestCoverageVerification {
        def Qdomains = []
        // 패키지 + 클래스명
        for (qPattern in '*.QA'..'*.QZ') { // qPattern = '*.QA', '*.QB', ... '*.QZ'
            Qdomains.add(qPattern + '*')
        }

        violationRules {
            rule {
                enabled = true
                element = 'CLASS'

                limit {
                    counter = 'LINE'
                    value = 'COVEREDRATIO'
                    minimum = 0.80
                }

                limit {
                    counter = 'BRANCH'
                    value = 'COVEREDRATIO'
                    minimum = 0.80
                }

                excludes = [] + Qdomains // 제외할 Qdomains 패턴 추가
            }
        }
    }
}

 

'*.QA*' 부터 '*.QZ*' 까지의 모든 값을 만들어서 Qdomains 리스트에 저장하고 excludes에 추가해주면서 해결합니다. 위와 같이 설정하면 커버리지 측정은 무시할 수 있지만, 여전히 리포트는 수집하여 표시됩니다. 따라서 리포트에서도 제거하는 작업을 진행해야 합니다. 여기서는 주의해야할 것이 앞선 작업에서는 패키지.클래스명 으로 했지만 여기서는 디렉토리 를 경로로 잡아야 합니다.

 

jacocoTestReport {
        reports {
            html.enabled true
            xml.enabled false
            csv.enabled true
        }

        def Qdomains = []
        for(qPattern in "**/QA" .. "**/QZ"){
            Qdomains.add(qPattern+"*")
        }

        afterEvaluate {

            classDirectories.setFrom(files(classDirectories.files.collect {
                fileTree(dir: it,
                        exclude: [] + Qdomains)
            }))
        }

        finalizedBy 'jacocoTestCoverageVerification'
}

 

afterEvaluate는 gradle의 빌드 라이프 사이클에 대한 메서드입니다. 프로젝트가 평가된 후 실행할 수 있도록 도와줍니다. classDirectories는 커버리지가 리포트로 작성할 소스 파일을 말합니다. 여기서는 setFrom 메서드를 통해 이를 설정해줍니다. files는 지정된 파일을 포함하는 ConfigurableFileCollection 타입을 반환합니다. files 내부 코드는 정확하지는 않지만, 기존의 classDirectories 의 파일들을 돌면서 각 파일을 통해 계층 구조로 된 파일 컬렉션인 fileTree 을 생성합니다. 그리고 excludes 로 Qdomains 리스트를 지정합니다. 위 과정을 거치면 리포트에는 Qdomain 클래스가 모두 제외되게 됩니다.

 

Lombok 제거

 

자주 사용하는 getter, setter, builder는 굳이 테스트하고 싶지 않을 수 있습니다.

lombok.addLombokGeneratedAnnotation = true

 

루트 디렉토리에 lombok.config 파일을 만들고 위 코드를 추가하면 테스트 코드에서 제외됩니다.

 

최종 build.gradle

 

plugins {
    id 'org.springframework.boot' version '2.6.2'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'

    // jacoco
    id 'jacoco'
}

// querydsl
apply plugin: "io.spring.dependency-management"

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'

    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // querydsl
    implementation("com.querydsl:querydsl-core")
    implementation("com.querydsl:querydsl-jpa")
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa"
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    runtimeOnly 'com.h2database:h2'
}

// querydsl
sourceSets {
    main {
        java {
            srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
        }
    }
}

test {
    useJUnitPlatform()
    finalizedBy 'jacocoTestReport'
}


jacoco {
    toolVersion = '0.8.7'
}

jacocoTestReport {
    reports {
        html.enabled true
        xml.enabled false
        csv.enabled true
    }

    def Qdomains = []
    for(qPattern in "**/QA" .. "**/QZ"){
        Qdomains.add(qPattern+"*")
    }

    afterEvaluate {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it,
                    exclude: [] + Qdomains)
        }))
    }

    finalizedBy 'jacocoTestCoverageVerification'
}

jacocoTestCoverageVerification {
    def Qdomains = []
    // 패키지 + 클래스명
    for (qPattern in '*.QA'..'*.QZ') { // qPattern = '*.QA', '*.QB', ... '*.QZ'
        Qdomains.add(qPattern + '*')
    }

    violationRules {
        rule {
            enabled = true // 활성화
            element = 'METHOD' // 클래스 단위로 커버리지 체크
            // includes = []

            // 라인 커버리지 제한을 80%로 설정
            limit {
                counter = 'LINE'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

            // 브랜치 커버리지 제한을 80%로 설정
            limit {
                counter = 'BRANCH'
                value = 'COVEREDRATIO'
                minimum = 0.80
            }

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


            excludes = [] + Qdomains // 제외할 Qdomains 패턴 추가
        }

    }
}

 

 

참고
Gradle 프로젝트에 JaCoCo 설정하기

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감/반응 부탁드립니다.