포스트

Jacoco와 Gradle을 사용한 테스트 커버리지 설정

Jacoco와 Gradle을 사용한 테스트 커버리지 설정

작성한 테스트가 어디까지 로직을 얼마나 커버 하는지 지표를 보여주는 것을 테스트 커버리지 라고 합니다. 설정한 테스트 커버지리를 넘지 못하면 빌드에 실패하게 만듦으로써 테스트 작성에 강제성을 부여할 수 있습니다.

자바 진영에는 Jacoco 라이브러리를 이용하면 쉽게 테스트 커버리지를 확인 할 수 있습니다.

전체 코드

  • build.gradle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
plugins {
  id 'java'
  id 'org.springframework.boot' version '3.2.3'
  id 'io.spring.dependency-management' version '1.1.4'
  id 'jacoco' // 추가
}

group = 'com.abcdejoji'
version = '0.0.1-SNAPSHOT'

java {
  sourceCompatibility = '21'
}

repositories {
  mavenCentral()
}

dependencies {
  ...
}

jacoco { // 추가
  toolVersion = '0.8.11'
}

test {
  useJUnitPlatform()
}

jacocoTestReport { // 추가
  dependsOn 'test'

  reports {
    html.required = true
    xml.required = false
    csv.required = false
  }

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

  afterEvaluate {
    classDirectories.setFrom(files(classDirectories.files.collect {
      fileTree(
        dir: it,
        exclude: ["com/abcdejoji/**/*Application*"] + qTypes
      )
    }))
  }
}

jacocoTestCoverageVerification { // 추가
  dependsOn 'jacocoTestCoverageVerification'

  def qTypes = []
  for (qPattern in '*.entity.QA'..'*.entity.QZ') {
    qTypes.add(qPattern + '*')
  }

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

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

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

      limit {
        counter = 'LINE'
        value = 'TOTALCOUNT'
        maximum = 150
      }

      excludes = ["**.*Application*"] + qTypes
    }
  }
}

자세히 알아보기

dependsOn은 해당 태스크가 실행되기 전에 실행 할 태스크를 지정합니다.

plugins

1
2
3
4
plugins {
  id 'jacoco'
}

빌드 과정에 jacoco, jacocoTestReport, jacocoTestCoverageVerification 태스크를 인식할 수 있도록 해줍니다.

jacoco

1
2
3
4
jacoco {
  toolVersion = '0.8.11'
}

JDK 버전에 호환되는 버전으로 설정하면 됩니다. 저는 작성하는 시점에 JDK 21을 사용중이기에 0.8.11로 설정하였습니다.

💡 호환 되는 버전 확인

https://www.jacoco.org/jacoco/trunk/doc/changes.html

jacocoTestReport

1
2
3
4
5
6
7
8
9
10
jacocoTestReport {
  dependsOn 'test'

  reports {
    html.required = true
    xml.required = false
    csv.required = false
  }
}

리포트를 어떤 형식으로 생성할지 지정합니다. 저는 HTML만 생성되도록 하였습니다.

리포트에서 제외

1
2
3
4
5
6
7
8
9
10
11
12
13
jacocoTestReport {
  ...

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

제외시킬 파일의 경로를 적어주면 됩니다. 와일드 카드 사용이 가능합니다.

jacocoTestCoverageVerification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
jacocoTestCoverageVerification {
  dependsOn 'jacocoTestCoverageVerification'

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

      // includes = []

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

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

      limit {
        counter = 'LINE'
        value = 'TOTALCOUNT'
        maximum = 150
      }

      excludes = ["**.*Application*"]
    }
  }
}

  • enable: rule의 활성화 여부
  • element: 커버리지를 체크할 기준 설정
    • BUNDLE: 패키지 번들
    • CLASS: 클래스
    • GROUP: 논리적 번들 그룹
    • METHOD: 메소드
    • PACKAGE: 패키지
    • SOURCEFILE: 소스 파일
    • Default: BUNDLE
  • includes
    • rule의 적용 대상을 package 수준으로 정의
    • Default: 전체 Package
  • limit: 커버리지 측정 단위 지정
    • counter: 커버리지 측정의 최소 단위
      • LINE: 빈 줄을 제외한 실제 코드의 라인 수, 라인이 한 번이라도 실행되면 실행된 것으로 간주
      • BRANCH: 조건문 등의 분기 수
      • CLASS: 클래스 수, 내부 메소드가 한 번이라도 실행된다면 실행된 것으로 간주
      • COMPLEXITY: 복잡도
      • INSTRUCTION: Java 바이트 코드 명령 수
      • METHOD: 메소드 수, 메소드가 한 번이라도 실행 된다면 실행된 것으로 간주
      • Default: INSTRUCTION
    • value: 측정한 커버리지를 어떤 형식으로 보여줄 것인지를 의미
      • COVEREDRATIO: 커버된 비율. 0: 0%, 1: 100%
      • COVEREDCOUNT: 커버된 개수
      • MISSEDCOUNT: 커버되지 않은 개수
      • MISSEDRATIO: 커버되지 않은 비율. 0: 0%, 1: 100%
      • TOTALCOUNT: 전체 개수
      • Default: COVEREDRATIO
    • minimum: counter 값을 value에 맞게 표현했을 때 최솟값을 의미
      • 이 값으로 jacocoTestCoverageVerification의 성공 여부가 결정
  • excludes: 제외할 클래스 지정
    • 패키지 + 클래스명 으로 작성되며 , ?를 사용 가능

특정 메소드 커버리지 제외

메소드 제외 이슈 Lombok을 사용하지 않고 프로젝트를 진행 하던 중 생성자, equals, hashCode를 커버리지에서 제외하려 하였으나 excludes에 아무리 추가해도 제외되지 않았습니다.

찾아보니 element = 'CLASS'로 설정하면 제외 가능한 수준도 클래스로 되어 메소드 단위는 제외가 안된다고 합니다.

jacoco 0.8.2 부터는 어노테이션을 만들어 메소드를 제외 시킬 수 있게 되었습니다.

어노테이션 조건

  • Retention: 런타임 or 클래스
  • 이름에 Generated이 포함
1
2
3
4
5
6
@Retention(RetentionPolicy.RUNTIME)
@Target({METHOD, CONSTRUCTOR})
public @interface ExcludeTestGenerated {
}

저는 명백히 테스트에서 제외한다 라는 의미를 주고 싶어 위와 같이 만들어 사용 중입니다.

1
2
3
4
5
6
@ExcludeTestGenerated
public CreatePostService(SavePostPort savePostPort) {
  this.savePostPort = savePostPort;
}

위 처럼 제외할 생성자나 메소드에 어노테이션을 추가하면 테스트 커버리지 측정에서 제외됩니다.

Lombok 테스트 커버리지 제외

Lombok을 사용하여 생성된 부분들 마저 모두 테스트하기에는 비효율적이라 생각하기에 Lombok을 사용한 부분은 모두 테스트 커버리지 측정에서 제외하도록 설정하겠습니다.

lombok.config

1
lombok.addLombokGeneratedAnnotation = true

QueryDSL 사용 시, Q-Type 테스트 커버리지 제외

QueryDSL 짧막한 지식 Java를 사용해서 개발을 하면 ORM으로 보통 JPA를 많이 사용합니다.

여기서 JPA를 Type-Safe하게 작성하며 동적 쿼리도 간단하게 만들 수 있는 QueryDSL을 얹어 사용하게 되는데 QueryDSL은 @Entity가 붙은 클래스를 찾아 Q-Type을 생성합니다.

Q-Type을 통해 쿼리를 작성하게 됩니다.

jacocoTestCoverageVerification

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
jacocoTestCoverageVerification {
  dependsOn 'jacocoTestCoverageVerification'

  def qTypes = []
  for (qPattern in '*.entity.QA'..'*.entity.QZ') {
    qTypes.add(qPattern + '*')
  }

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

      // includes = []

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

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

      limit {
        counter = 'LINE'
        value = 'TOTALCOUNT'
        maximum = 150
      }

      excludes = ["**.*Application*"] + qTypes
    }
  }
}

모든 Q-TypeQ + Entity 클래스명이기 때문에 QA ~ QZ로 시작하는 모든 클래스를 대상에서 제외 시켰습니다.

저는 entity 패키지 안에 넣어서 작성할 예정이라 entity 패키지도 적었지만 *.QA처럼만 작성해도 됩니다. 이렇게 만든 제외 클래스를 excludes에 추가하면 됩니다.

QNA와 같은 엔티티 이슈 QNA같은 Entity명으로 작성 시 당연히 테스트 커버리지에서 제외 됩니다. 이 경우는 특별히 Qna 처럼 조금 다른 방식이나 네이밍으로 생성해 주시기 바랍니다.

jacocoTestReport

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
jacocoTestReport {
  ...

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

  afterEvaluate {
    classDirectories.setFrom(files(classDirectories.files.collect {
      fileTree(
        dir: it,
        exclude: ["com/abcdejoji/**/*Application*"] + qTypes
      )
    }))
  }
}

테스트 커버리지에서 제외 시키는 것과는 다르게 여기서는 폴더 경로로 지정을 해주어야 합니다. 이제 다시 실행하고 리포트를 보면 Q-Type은 더이상 나오지 않는 것을 확인 할 수 있습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.