좌충우돌 jacoco 적용기

ds_chanin

·

2020. 2. 29. 03:55


스터디 블로그 코드블럭 상태가 이상해서 먼저 여기에 올린당..

좌충우돌 Jacoco 적용기

백기선님의 더 자바 강의를 보던 중 바이트 코드를 다루는 방법의 예시로 jacoco가 언급되었고 테스트 커버리지를 측정하여 build를 성공, 실패 시킬수 있다는 점을 알게되었습니다.

이를 이용하여 ''스터디에서 진행하는 장기 프로젝트와 토이 프로젝트에 적용하면 재미있겠다!' 라는 생각에 적용하며 겪은 내용을 정리해 보고자 합니다!

Jacoco 적용하기

단순히 적용하는 것은 어렵지 않았습니다!
jacoco 유저 가이드우아한 형제들의 jacoco 포스팅을 보면서 쉽게 적용할 수 있었습니다.

jacoco plugin 추가

먼저 plugins 블록에 id 'jacoco' 를 추가해 주면

plugins {
    id 'jacoco'
}

image

jacocoTestReportjacocoTestCoverageVerification task가 gradle verification항목에 추가됩니다.

주의 해야 할 점은 생성된 두 개의 task는 test 가 먼저 실행된 다음에 실행이 되어야 합니다.

image

jacoco 유저 가이드에서

jacoco plugin이 성공적으로 적용되었다면 jacoco 로 이름이 붙은 JacocoPluginExtension 타입의 project extentions을 사용하여 빌드 파일에서 사용 될 기본적인 설정을 해줄수 있습니다.
JacocoPluginExtension 에서 설정해 줄 수 있는 값은 아래 두가지 입니다.

  1. toolVersion : Jacoco의 jar 버전
  2. reportsDir : Jacoco report 결과물 디렉토리

우리는 reportsDir 은 jacocoTestReport 에서 설정해면 되니까 toolVersion만 다음과 같이 명시해 주도록 합시다. (작성일(2020.02.28) 기준 기본값이 0.8.5 이다.)

jacoco {
    toolVersion = "0.8.5"
}

이곳에서 버전 정보를 확인 할 수 있다.

image

jacocoTestReports 설정

이제 우리가 테스트 결과를 받는 부분을 설정해 주기 위해 jacocoReports 설정을 해야합니다.
jacocoReport task는 html, csv, xml 형태로 커버리지 측정 결과를 알려주는 역할을 합니다.

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

위와 같이 설정한다면 html과 csv 형태로 report를 제공합니다.

csv 형태로 제공해야 Sonar Qube에서 커버리지 측정을 확인할 수 있습니다.

이 부분에서 추가적으로 설정해 줄 수 있는 설정 값은 이곳에서 확인 할 수 있습니다.

image

jacocoTestCoverageVerification

jacoco의 꽃이라 생각하는 커버리지 검증 수준을 정의하는 부분입니다.
이 부분에서 jacoco의 report 검사하여 설정한 최소 수준을 달성하지 못하면 task는 실패를 하게 됩니다.

여러 수준의 정의를 violationRules 에서 다수의 rule 에 정의하여 사용할 수 있습니다.

jacocoTestCoverageVerification {
        violationRules {
            rule {
                enabled = true
                element = 'CLASS'
                // includes = []

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

                excludes = []
            }
          
          	rule {
            	...
          	}

        }
    }

가장 중요한 부분이라 생각하는 만큼 각 설정값의 의미들에 대해 알아보도록 하겠습니다.

  1. enabled : 해당 rule의 활성화 여부를 boolean으로 나타냅니다.
    명시적으로 지정하지 않으면 default로 true입니다.

  2. element : 측정의 큰 단위를 나타냅니다.

  3. includes : rule 적용 대상을 package 수준으로 정의합니다.
    아무런 설정을 하지 않는다면 전체 적용됩니다.

  4. limit : rule의 상세 설정을 나타내는 block 입니다.

    • counter : 커버리지 측정의 최소 단위를 나타냅니다.
      이 때 측정은 java byte code가 실행된 것을 기준으로 counting됩니다.
      counter의 종류는 CLASS, METHOD, LINE, BRANCH 등이 있습니다.

      • CLASS : 클래스 내부 메소드가 한번이라도 실행된다면 실행된 것으로 간주됩니다.
      • METHOD : 클래스와 마찬가지로 METHOD가 한번이라도 실행되면 실행된 것으로 간주됩니다.
      • LINE : 한 라인이라도 실행되었다면 측정이 됩니다.
        소스 코드 포맷에 영향을 받는 측정방식입니다.
      • BRANCH : if, switch 구문에 대한 커버리지 측정을 합니다.
      • INSTRUCTION : jacoco의 가장 작은 측정 방식입니다. (바이트 코드를 읽습니다.)
        LINE과 다르게 소스 코드 포맷에 영향을 받지 않습니다.
    • value : 측정한 counter의 정보를 어떠한 방식으로 보여줄지 정합니다.
      value의 종류는 TOTALCOUNT, COVEREDCOUNT, MISSEDCOUNT, COVEREDRATIO, MISSEDRATIO 가 있습니다.
      이름에서 충분히 그 뜻을 유추 할 수 있기때문에 따로 설명은 하지 않겠습니다.
      커버리지 측정에서 우리는 얼마나 우리의 코드가 테스트 되었는지 확인하면 되기 때문에 커버된 비율을 나타내는 COVEREDRATIO를 사용합시다.

    • minimum : count값을 value에 맞게 표현했을때 최소 값을 나타냅니다.
      이 값으로 jacoco coverage verification이 성공할지 못할지 판단합니다.
      해당 값은 0.00 부터 1.00사이에 원하는 값으로 설정해주면 됩니다.

  5. excludes

    • verify 에서 제외할 클래스를 지정할 수 있습니다.
      패키지 레벨의 경로를 지정해주도록 합니다.
      경로에는 와일드 카드로 *?를 사용할 수 있습니다.
      이 부분은 아래에서 다시한번 다루겠습니다!

jacoco 실행

이제 필요한 설정은 모두 끝마쳤으니 jacoco의 report를 받아봅시다!

테스트를 위해서 아래와 같이 양수 값을 나타내는 클래스를 만들고 테스트 코드를 작성해 봅시다.

public final class PositiveNumber {
    private final long value;

    public PositiveNumber(long value) {
        validate(value);
        this.value = value;
    }

    private void validate(long value) {
        if (value <= 0) {
            throw new IllegalArgumentException(String.format("%d 는 양수가 아닙니다.", value));
        }
    }

    public PositiveNumber add(PositiveNumber positiveNumber) {
        return new PositiveNumber(value + positiveNumber.value);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PositiveNumber that = (PositiveNumber) o;
        return value == that.value;
    }

    @Override
    public int hashCode() {
        return Objects.hash(value);
    }
}

테스트 코드 (Junit 4)

import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class PositiveNumberTest {

    @Test
    public void notPositiveValueThrowException() {
        long notPositive = 0;

        assertThatThrownBy(() -> new PositiveNumber(notPositive))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

처음에 언급했듯이 jacoco의 report를 받기 위해서는 task의 순서가 중요합니다.
jacoco 문서에 따르면 test task를 먼저 실행한 다음 jacoco task 가 실행되어야 한다고 합니다.

test -> jacocoTestReport -> jacocoTestCoverageVerification

위 순서대로 task가 실행되도록 finalizedBy를 이용해서 아래와 같이 수정해 주도록 합시다.

image

그리고 ./gradlew test 명령어로 test task를 실행하면..!

image

빌드 실패합니다!
우리가 기존에 설정한 LINE 커버리지는 60%인데 테스트 코드는 37% 이기 때문입니다.

이제 생성된 report를 확인해 봅시다.

image

image

뒤늦게 equals , hashCode 메소드를 추가해서 이 부분에선 나타나지 않았습니다 ㅎㅎ..

초록색은 conter 기준에 통과한 부분, 빨간색은 통과하지 못한 부분, 노란색은 절반만 통과한 부분을 나타냅니다.

현재 테스트는 Junit4 기반으로 작성되어 있습니다.
의존성에 Junit5를 추가하고 Junit5기반 테스트를 추가하여 부족한 커버리지를 올려보도록 합시다.

testImplementation('org.junit.jupiter:junit-jupiter:5.6.0')

추가된 JUnit5 기반 테스트 코드

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class PositiveNumberJunit5Test {

    @DisplayName("양수 생성")
    @Test
    void createTest() {
        long positive = 1;
        PositiveNumber positiveNumber = new PositiveNumber(positive);

        assertThat(positiveNumber).isEqualTo(new PositiveNumber(1));
    }

    @DisplayName("양수 덧셈")
    @Test
    void addTest() {
        //given
        PositiveNumber result = new PositiveNumber(2);

        PositiveNumber positiveNumber1 = new PositiveNumber(1);
        PositiveNumber positiveNumber2 = new PositiveNumber(1);

        //when
        PositiveNumber expect = positiveNumber1.add(positiveNumber2);

        
        //then
        assertThat(expect).isEqualTo(result);
    }
}

image

가뿐하게 새로 작성한 테스트 코드가 통과하네요!
이제 jacoco의 성공한 report를 받아보러 test task를 실행해 봅시다!

image

엥? 실패합니다;
아무리 jacoco와 인텔리제이 커버리지 측정방식이 달라도 인텔리제이 테스트로 실행을 해보면 아래와 같이 92%의 라인 커버리지를 보여주는 데도 말이죠!

image

이는 gradle이 junit4 테스트만 읽고 있었기 때문인데요!

이를 해결하기 위해서 test, dependencies block에 junit4와 junit5를 동시에 쓸수 있도록 다음과 같이 추가해 줍니다.

image

그리고 다시 test task를 실행하면..!

image

ㅠㅠ 드디어 통과했습니다!

image

전부 통과하지는 못했지만 우리의 기준치인 60%는 넘겼네요!!!

기본 설정 바꾸기

jacoco에 기본적으로 설정되어 있는 값으로도 사용하는데에는 크게 문제가 없었습니다.
만약 개발환경 혹은 테스트 설정을 바꿔야 하는경우 test block 내의 jacoco block에서 설정값을 바꿔주면 됩니다.
아래는 전부 기본설정 값이 세팅되어 있는 경우입니다.

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

Spring boot Multi Module 에서 적용하기

이제 우리의 스프링 부트 프로젝트에 적용을 하러 갑시다!

아래와 같이 core, api 단 두개의 모듈을 가지고 있는 아주 간단한 멀티 모듈 프로젝트를 만들겠습니다.

core 모듈은 QueryDSL 의존성을 가지고 있는 도메인 모듈입니다.

image

위와 같이 만들고 ROOT프로젝트의 build.gradle 을 다음과 같이 작성해줍니다.

buildscript {
	ext {
		springBootVersion = '2.2.5.RELEASE'
		querydslPluginVersion = '1.0.10'
	}
	repositories {
		mavenCentral()
		maven { url "https://plugins.gradle.org/m2/" }
	}
	dependencies {
		classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
		classpath "io.spring.gradle:dependency-management-plugin:1.0.9.RELEASE"
		classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:${querydslPluginVersion}")
	}
}

subprojects {
	apply plugin: 'java'
	apply plugin: 'org.springframework.boot'
	apply plugin: 'io.spring.dependency-management'

	repositories {
		mavenCentral()
	}

	group = 'com.javabom'
	version = '0.0.1-SNAPSHOT'
	sourceCompatibility = '1.8'

	dependencies {
		implementation('org.projectlombok:lombok')
		annotationProcessor ('org.projectlombok:lombok')

		implementation 'org.junit.jupiter:junit-jupiter'
		testCompile group: 'org.assertj', name: 'assertj-core', version: '3.14.0'
	}

	test {
		useJUnitPlatform()
	}

}

def queryDslProjects = [project(':jacoco-core')]
configure(queryDslProjects) {

	def queryDslSrcDir = 'src/main/generated'

	apply plugin: "com.ewerk.gradle.plugins.querydsl"

	querydsl {
		library = "com.querydsl:querydsl-apt"
		jpa = true
		querydslSourcesDir = queryDslSrcDir
	}

	sourceSets {
		main {
			java {
				srcDirs = ['src/main/java', queryDslSrcDir]
			}
		}
	}
	dependencies {
		compile("com.querydsl:querydsl-jpa")
		compile("com.querydsl:querydsl-apt")
	}

	compileQuerydsl {
		options.annotationProcessorPath = configurations.querydsl
	}

	configurations {
		compileOnly {
			extendsFrom annotationProcessor
		}
		querydsl.extendsFrom compileClasspath
	}
}

이제 우리는 jacoco를 모든 모듈의 테스트에 적용하고 싶기 때문에 subprojects block에 설정값을 추가하고
plugins block 대신 apply plugin 으로 jacoco 를 추가해주면 됩니다!

멀티 모듈 프로젝트의 뼈대를 만든다음 적용하면 다음과 같이 build.gradle이 완성됩니다!

buildscript {
	ext {
		springBootVersion = '2.2.5.RELEASE'
		querydslPluginVersion = '1.0.10'
	}
	repositories {
		mavenCentral()
		maven { url "https://plugins.gradle.org/m2/" }
	}
	dependencies {
		classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
		classpath "io.spring.gradle:dependency-management-plugin:1.0.9.RELEASE"
		classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:${querydslPluginVersion}")
	}
}

subprojects {
	apply plugin: 'java'
	apply plugin: 'org.springframework.boot'
	apply plugin: 'io.spring.dependency-management'
	apply plugin: 'jacoco'

	repositories {
		mavenCentral()
	}

	jacoco {
		toolVersion = "0.8.5"
	}

	group = 'com.javabom'
	version = '0.0.1-SNAPSHOT'
	sourceCompatibility = '1.8'

	dependencies {
		implementation('org.projectlombok:lombok')
		annotationProcessor ('org.projectlombok:lombok')

		implementation 'org.junit.jupiter:junit-jupiter'
		testCompile group: 'org.assertj', name: 'assertj-core', version: '3.14.0'
	}

	test {
		useJUnitPlatform()
		finalizedBy 'jacocoTestReport'
	}

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

		finalizedBy 'jacocoTestCoverageVerification'
	}

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

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

				excludes = []
			}

		}
	}
}

def queryDslProjects = [project(':jacoco-core')]
configure(queryDslProjects) {

	def queryDslSrcDir = 'src/main/generated'

	apply plugin: "com.ewerk.gradle.plugins.querydsl"

	querydsl {
		library = "com.querydsl:querydsl-apt"
		jpa = true
		querydslSourcesDir = queryDslSrcDir
	}

	sourceSets {
		main {
			java {
				srcDirs = ['src/main/java', queryDslSrcDir]
			}
		}
	}
	dependencies {
		compile("com.querydsl:querydsl-jpa")
		compile("com.querydsl:querydsl-apt")
	}

	compileQuerydsl {
		options.annotationProcessorPath = configurations.querydsl
	}

	configurations {
		compileOnly {
			extendsFrom annotationProcessor
		}
		querydsl.extendsFrom compileClasspath
	}
}

측정하지 않을 클래스 설정하기

jacoco 적용하기에서 한번 언급을 했던 측정하지 않을 클래스를 설정하는 부분입니다.

JPA를 쓰고 QuertDSL을 쓴다면 Q domain이 생기는데 이 부분은 테스트할 필요가 없습니다.

아래와 같이 domain 객체로 Book과 Question Entity를 작성후 querydslComplie task를 실행후 test를 실행해 보면!

image

보시다시피 Entity의 테스트 커버리지뿐 아니라 Qdomain들의 테스트 커버리지까지 측정해서 빌드가 실패하게 되는것을 확인할 수 있습니다.
이를 해결하기위해 excludes패키지+클래스 명으로 Qdomain을 제외시켜주면 되는데요!
단순히 다음과 같이 *.Q* 라고 지정해준다면 한가지 문제가 발생합니다.

excludes = ["*.Q*"]

바로 Entity를 비롯한 Q로 시작하는 모든 클래스를 검증 대상으로 삼지 않는다는 것인데요.

image

이를 해결하기 위해 저는 꼼수(?)를 사용했습니다.
Qdomain의 경우 맨 앞글자가 Q 그다음 글자도 영문자 대문자로 시작한다는 것을 확인할 수 있는데요.
따라서 다음과 같이 for loop를 이용해서 해당 패턴의 Qdomain경로를 모아서 exclude에 추가해 주면됩니다!
이를 작성하면 다음과 같은 코드가 됩니다.

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

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

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

                excludes = [] + Qdomains

            }

        }
    }

test를 해보면 우리가 원하는대로 Entity만 검증하고 있는 것을 확인할 수 있습니다!

image

jacocoReport에서 수집되지 않도록 제외하기

테스트 코드를 모두 작성해서 빌드를 통과하더라도 한가지 문제점이 남아있었습니다.

jacoco의 테스트 검증 대상에서 벗어나도록 설정은 했지만 report 대상에는 그대로 남아 있어 프로젝트 커버리지 비율을 떨어뜨리고 있었습니다..!!

image

이를 해결하기 위해 jacocoTestReport block에서 우리가 설정한 디렉토리는 report 결과에 포함하지 않도록 해줍시다!
여기서는 패키지+클래스 경로가 아닌 디렉토리 경로를 잡아줘야합니다.

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'
    }

image

이제 한층 깔끔한 report가 생성되는군요!!

Lombok과 DTO

Lombok을 이용해서 우리는 getter, Builder와 같은 메서드를 손쉽게 쓸 수 있습니다.

하지만 jacoco 테스트에서는 이것도 문제가 될 수 있는데요!
단순히 getter와 builder가 테스트 되는지 알고 싶지 않을수 있고 테스트 커버리지에 영향을 미치는것 또한 바라지 않을 수 있습니다.

image

이를 해결해주는 방법은 아주 단순합니다!

image

lombok.addLombokGeneratedAnnotation = true

위와 같이 lombok.config를 추가해준 다음 한 줄을 추가해주면 lombok에 의해 generated된 메서드는 테스트 검증에서 제외되는 것을 아래에서 확인할 수 있습니다!

image

과제

모든것이 해결된 것 처럼 보이지만 해결해야할 과제가 남아있습니다.

커버리지 측정 제외 대상과 report 제외 대상이 1:1로 일치하지 않습니다.
커버리지 측정 제외 대상은 패키지+클래스로 지정한 반면
report 제외 대상은 디렉토리 기준으로 설정되어 있습니다.

위 문제를 해결할 수 있는 방법을 찾아야할 것 같습니다.

jacoco 사용시 테스트에서 주의점

jacoco를 적용한뒤 test code를 작성할 때 주의해야할 점이 있습니다.

jacoco를 적용하면 test를 할 때 jacoco가 테스트 정보를 수집하기 위해
Synthetic 필드와 메소드를 각각 1개씩 추가한다는 것인데요.

관련 jacoco issue 링크

  • Synthetic 필드와 메서드는 컴파일러에 의해 생성된 녀석들입니다.

이를 확인하기 위해 테스트 코드를 작성해 보도록 하겠습니다.

    @Test
    void reflectionTest() {
        Field[] declaredFields = PositiveNumber.class.getDeclaredFields();

        for (Field field : declaredFields) {
            if (field.isSynthetic()) {
                System.out.println(field + " jacoco가 넣은 필드");
            } else {
                System.out.println(field + " 기존 필드");
            }
        }
        
        assertThat(declaredFields).hasSize(2);
    }

2개의 필드를 가지고 있을 것이라는 테스트가 성공한것을 확인하고 console 창을 보면

image

위와 같이 필드가 추가되어 있는 것을 확인할 수 있습니다.

만약 filed의 갯수나 reflection이 필요한 테스트 코드를 작성할 경우 위와 같은 부분을 주의하고
fieldisSynthetic() 메소드를 이용하여 필터링을 거친뒤에 사용해 주어야 합니다.