서론
JaCoCo를 도입하게 된 계기
- 여태 앞서 우리는 테스트 코드(단위, 통합)를 짰다.
- 그럼 우리는 이제 테스트 코드가 어디까지 본 코드에 대한 테스트를 진행했는지 파악해야하지 않은가?
- 중간중간 놓치는 부분이 있을 수도 있다. → 사람이 하는 일이라 어쩔수 없음
- 그래서 우리는 테스트 코드에 대한 테스트 커버리지를 확인해봐야한다.
- 테스트 커버리지를 확인하게 된다면 테스트를 못한 코드라인에 대해서 파악이 가능하므로, 테스트에 대한 신뢰성과 완전성을 높일 수 있다.
- 테스트에 대한 신뢰성, 완정성을 높이면 프로젝트 품질을 항샹시키고 잠재적 버그를 줄일수 있다.
- 또한 테스트 커버리지는 코드 품질의 중효한 지표로 간주되어 지속적인 통합과 배포 프로세스에서 핵심적인 역할을 한다.
본론
JaCoCo란?
- JaCoCo는 실행 경로, 조건문, 분기, 클래스, 메서드 등 다양한 레벨에서의 코드 커버리지를 분석한다.
- 분석 결과를 HTML, XML, CSV 형식으로 출력하여, 눈으로 확인하기 쉽게 만들어준다.
- JaCoCo는 애플리케이션을 실행하는 동안 실시간으로 코드 커버리지 데이터를 수집한다.
- Maven, Gradle, Ant와 같은 빌드 도구 및 Jenkins, SonarQube와 같은 CI/CD 도구와 잘 통합된다.
IntelliJ 테스트 커버리지 vs JaCoCo 테스트 커버리지
기능/특징 | 인텔리제이 테스트 커버리지 | JaCoCo 테스트 커버리지 |
---|
가격 | 무료 (인텔리제이 라이센스에 포함) | 오픈소스 (무료) |
통합도 | 인텔리제이에 완벽히 통합 | Maven, Gradle, Ant 등에 통합 가능 |
코드 커버리지 지표 | 기본적인 커버리지 정보 제공 | 다양한 지표 제공 |
실시간 분석 | 제한적 | 가능 |
리포트 형식 | 기본적인 형식 제공 | HTML, XML, CSV 등 다양한 형식 제공 |
CI/CD 통합 | 제한적 | Jenkins, SonarQube 등과 잘 통합 |
커스텀 리포트 생성 | 어려움 | 가능 |
다양한 빌드 도구와의 통합 | 제한적 | 넓은 범위의 빌드 도구 지원 |
커뮤니티 지원 | 제한적 | 활발한 오픈소스 커뮤니티 지원 |
- JaCoCo는 더 다양한 코드 커버리지 지표를 제공하여, 높은 수준의 분석을 가능하게 한다.
- JaCoCo는 CI/CD 파이프라인과 통합이 쉬워, 자동화된 환경에서의 테스트 커버리지 분석 및 보고에 유용하다.
- 여러 프로젝트나 팀 간에 동일한 테스트 커버리지 도구를 사용하면 분석 결과의 일관성을 유지할 수 있다.
- JaCoCo는 다양한 형식의 리포트를 생성할 수 있어, 팀의 필요에 따라 맞춤화된 리포트를 생성할 수 있다.
- 위와 같은 이유로 인텔리제이에 테스트 커버리지보단 JaCoCo에 테스트 커버리지를 사용하게 되었다.
JaCoCO 플러그인 추가
plugins {
id 'jacoco'
}
jacoco {
toolVersion = '0.8.8'
}
- Gradle 설정에 JaCoCo 플러그인을 추가하고, 플러그인 설정을 합니다.
reportsDir
로 테스트 결과 리포트를 저장할 경로를 바꿀 수 있습니다.
커버리지 리포트 생성
jacocoTestReport {
dependsOn test
reports {
xml.required = false
csv.required = false
html.required = true
}
afterEvaluate {
classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(dir: it, excludes: [ ... ])
})
)
}
finalizedBy 'jacocoTestCoverageVerification'
}
dependsOn test
→ jacocoTestReport가 실행 되기 전에 꼭 test 테스크가 실행되어야 한다는 의미이다.
afterEvaluate
→ 현재 프로젝트의 모든 설정이 평가된 후에 실행되는 블록입니다.
files(classDirectories.files.collect)
→ 테스트 커버리지를 측정할 클래스 파일들이 위치한 디렉토리들의 집합을 참조하고 파일 컬렉션으로 변환한다.
- 이 디렉토리는 보통 build/classes/java/main에 위치하고 있다.
fileTree(dir: it, excludes: [...])
→ fileTree로 위에서 선택한 디렉토리에 파일 트리를 생성하고, dir: it로 각각의 디렉토리를 나타내고 excludes로 특정 패턴에 일치하는 파일 또는 디렉토리를 제외하기 위한 목록이다.
finalizedBy 'jacocoTestCoverageVerification'
→ jacocoTestReport 테스크가 완료되면 jacocoTestCoverageVerification() 테스크를 실행한다.
커버리지 검증
jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true
element = 'CLASS'
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.90
}
limit {
counter = 'LINE'
value = 'TOTALCOUNT'
maximum = 200
}
limit {
counter = 'BRANCH'
value = 'COVEREDRATIO'
minimum = 0.90
}
excludes = [...]
}
}
}
violationRules
→ 검증을 위한 규칙들을 정의하는 섹션이다.
enabled = true
→ 규칙이 활성화 되어있음을 나타낸다.
element = 'CLASS'
→ 규칙이 적용되는 대상을 클래스 수준으로 설정한다.
limit
→ 테스트 커버리지의 특정 측면에 대한 제한을 정의한다.
- 첫 번째 limit : 라인 수준의 커버리지를 대상으로 커버된 라인의 비율이 90퍼 이상이어야한다라는 제약이다.
- 두 번째 limit : 라인 수준의 커버리지를 대상으로 전체 라인 수가 200을 초과하면 안 된다는 제약이다.
- 세 번째 limit : 분기 수준의 커버지리를 대상으로 커버된 분기 비율이 90퍼 이상이어야한다라는 제역이다.
excludes = [...]
→ 검증에서 제외할 클래스나 패키지 목록을 넣어준다.
JaCoCo와 JUnit 연결
tasks.named('test') {
useJUnitPlatform()
finalizedBy 'jacocoTestReport'
}
- 위 설정은 Gradle의 test 테스크와 JaCoCo를 연결한다.
useJUnitPlatform()
→ JUnit 5를 사용하여 테스트를 실행하도록 설정합니다.
finalizedBy 'jacocoTestReport'
→ test 테스크가 완료가 되면 jacocoTestReport 테스크각 실행된다.
- 이렇게 두지 않는다면 test 실행할 때 마다 일일이 jacocoTestReport 테스크를 실행해야해서 test를 돌릴 때 마다 생성이 되게 구현해두었다.
테스트 코드
- UserCheckController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserCheckController {
private final CheckUsernameQuery checkUsernameQuery;
@GetMapping("/username/{username}/exists")
public ResponseEntity<ReturnObject> checkUsername(
@PathVariable("username") String username
) {
User user = checkUsernameQuery.checkUsername(username);
if (user == null) {
ReturnObject returnObject = ReturnObject.builder()
.success(true)
.data(username)
.build();
return ResponseEntity.status(HttpStatus.OK).body(returnObject);
}
ReturnObject returnObject = ReturnObject.builder()
.success(false)
.errorCode(ErrorCode.ALREADY_REGISTERED_MEMBER)
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(returnObject);
}
}
- 간단한 username 중복체크 API이다.
- 밑에서 해당 API에 대한 단위 테스트를 진행하겠다.
- UserCheckControllerTest.java
@ExtendWith(MockitoExtension.class)
public class UserCheckControllerTest {
@Mock
private CheckUsernameQuery checkUsernameQuery;
@InjectMocks
private UserCheckController userCheckController;
private String testUsername = "testuser";
private User mockUser;
@BeforeEach
public void setUp() {
mockUser = User.builder()
.userId(1L)
.username(testUsername)
.nickname(testNickname)
.build();
}
@Test
@DisplayName("유저 이름이 존재하지 않을 때 성공 응답 반환")
public void shouldReturnSuccessWhenUsernameDoesNotExist() {
when(checkUsernameQuery.checkUsername(testUsername)).thenReturn(null);
ResponseEntity<ReturnObject> response = userCheckController.checkUsername(testUsername);
assertEquals(HttpStatus.OK, response.getStatusCode());
}
}
- 테스트 코드에 대한 리뷰는 앞서 작성한 블로그에 있기에 넘어가겠다.
테스트 실행
> Task :user-service:compileJava UP-TO-DATE
> Task :user-service:processResources UP-TO-DATE
> Task :user-service:classes UP-TO-DATE
> Task :user-service:compileTestJava UP-TO-DATE
> Task :user-service:processTestResources NO-SOURCE
> Task :user-service:testClasses UP-TO-DATE
> Task :user-service:test
> Task :user-service:jacocoTestReport
> Task :user-service:jacocoTestCoverageVerification
- 우리가 여기서 봐야할 부분은 3가지이다.
- Task :user-service:test
- Task :user-service:jacocoTestReport
- Task :user-service:jacocoTestCoverageVerification
- 우리는 위에서 순서를 정하지 않았는가, Test Task가 실행 되면 순차적으로 jacocoTestReport, jacocoTestCoverageVerification가 돌게 만들었기에 Task도 순차적으로 실행이 된 모습이다.
- jacocoTestReport Task가 완료가 됐다면 아래와 같은 디렉토리가 생성이 되었을 것이다.
Jacoco Report UI
- 다른 부분은 이미 내가 테스트 코드를 작성했기에 넘어가도 되고 우리가 중점으로 봐야할 부분은 UserCheckControllerTest에 대해서이다.
- 해당 테스트는 UserCheckController에서 확인을 해야하는데 보이는 페이지는 우리의 어플리케이션 디렉토리와 동일하니 찾아서 확인하면 된다.
- UserCheckController을 보니 Missed Instructions, Missed Branches이 각각 80%, 75%가 나왔다.
- Missed Instructions는 라인 커버리지이다. 라인 커버리지란 명령어(메소드 등)가 테스트가 진행이 됐는지를 확인하는 것이다.
- Missed Branches는 분기 커버리지이다. 분기 커버리지란 if-else문에서 둘다 테스트가 진행이 됐는지를 확인하는 것이다.
- 이미지를 보면 조금 더 편하게 확인이 가능할 것이다.
- Missed Instructions는 80프로가 나왔는데 그 이유는 메소드 안에 코드 라인이 12개씩 2개가 존재하고 있다.
- 우리는 username 중복 체크 성공 테스트는 진행하였지만 실패 테스트는 작성하지 않았다. → nickname 중복 체크 성공, 실패는 작성하였다는 가정이다.
- 따라서 실패 테스트에 대한 5줄을 검증하지 못한 것이다.
- 전체 24줄 중 5줄만 테스트를 못하였기에 (24-5)/24 * 100 = 79.16이 나온다. 아마도 1의 자리에서 반올림을 했기에 79%가 된 것 같다.
- Missed Branches는 75프로가 나왓는데 그 이유는 if 분기로 총 4가지로 나눠지지 않는가
- username 분기 2가지, nickname 분기 2가지로 총 4가지이다.
- 여기도 또한 username 중복 체크에 대한 실패 테스트가 없기에 총 4가지중 1가지에 대해서 테스트를 진행을 못하였다.
- 전체 4분기 중 1분기만 테스트를 못하였기에 (4-1)/4 * 100 = 75%가 되었다.
- 그럼 이젠 우리는 커버리지 검증 머릿말에서 라인 커버리지와 브런치 커버리지를 0.9(90%)로 두었으니 통과하지 못할 것이다.
- 보이는 것과 같이 90%를 기대했지만 라인 커버리지, 브런치 커버리지가 둘다 90%를 넘지 못했기에 jacocoTestCoverageVerification 테스크에서 에러를 뱉어낸 것이다.
- 이러면 좋은점이 있는데 테스트 커버리지에 대해서 어느정도 수준이 넘어가지 못하면 빌드를 못하게 하여 배포도 불가능한 상태로 만들수 있다.
- 이러면 커버리지 수치를 맞추기 위해 더더욱 테스트 코드를 깔끔하게 작성할 것이고 테스트 코드가 늘어남에 따라 버그가 줄어들 것이고 프로젝트에 완성을 높힐수 있다.
- 이젠 나머지 실패 테스트에 대해 작성해 보고 다시한번 테스트 커버리지를 확인해보자.
- 코드 커버리지, 분기 커버리지 모두 100%를 달성하였다.
- 물론 당연하게도 jacocoTestCoverageVerification에서도 당연하게 90%로 밑으로 떨어지는게 없기에 에러를 반환하지 않았다.
커버리지 기준 설정
jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true
limit {
counter = [...]
value = [...]
maximum = [...]
}
}
rule {
enabled = false
element = 'CLASS'
limit {
counter = [...]
value = [...]
minimum = [...]
}
excludes = [...]
}
}
}
- 커버리지 기준(Rule)을 보면 지금 2가지가 rule이 있는 것을 확인할 수 있다.
- rule을 기준으로 몇가지 더 추가할 수 있고, enabled로 룰을 사용할지 말지를 정할수 있다.
- element은 커버리지를 체크하는 기준이며 element를 따로 지정하지 않는다면 프로젝트 전체 파일을 합친 값을 기준으로 하고 element에 들어가는 변수는 다음과 같다
- BUNDLE (default): 패키지 번들
- PACKAGE: 패키지
- CLASS: 클래스
- SOURCEFILE: 소스파일
- METHOD: 메소드
- limit은 rule에 대한 제한 조건들을 설정하고 limit에 들어가는 설정 값들은 counter, value, minimum이 있다.
- counter은 검사할 테스트 커버리지 유형을 나타낸다. 유형들은 아래와 같다.
- LINE: 소스 코드 라인에 대한 커버리지.
- BRANCH: 코드의 분기점(예: if, switch 문)에 대한 커버리지.
- INSTRUCTION: 바이트 코드 명령어의 커버리지.
- METHOD: 메서드의 커버리지.
- CLASS: 클래스의 커버리지.
- value은 해당 커버리지 유형의 특정 값을 나타낸다. 주로 사용되는 값은 아래와 같다.
- TOTALCOUNT: 코드 섹션(라인, 브랜치, 명령어 등)의 전체 개수.
- MISSEDCOUNT: 테스트에 의해 커버되지 않은 코드 섹션의 개수.
- COVEREDCOUNT: 테스트에 의해 성공적으로 커버된 코드 섹션의 개수.
- MISSEDRATIO: 커버되지 않은 비율. 0부터 1 사이의 숫자로, 1이 100%입니다.
- COVEREDRATIO (default): 커버된 비율. 0부터 1 사이의 숫자로, 1이 100%입니다.
- minimum, maximum은 각각 최소 및 최대 허용값이다.
- minimum = 0.80 → 해당 커버리지가 최소 80%는 되어야 함을 나타낸다.
- maximum = 200 → 특정 값을 200 이하로 유지해야 함을 나타낸다.
- excludes은 커버리지 체크를 제외할 클래스들은 넣는 곳이다.
def Qdomains = []
for (qPattern in '*.QA'..'*.QZ') {
Qdomains.add(qPattern + '*')
}
excludes = [
"**.*Application*",
"**.*Config*",
"**.*Request*",
"**.*Response*",
"**.*Exception*"
] + Qdomains
- 내가 제외했던 것은 스프링 부트 어플리케이션 Application 클래스
- 각종 설정들이 포함된 Config 클래스
- 웹 통신만을 위한 Request/Response 클래스
- 에러 처리를 위한 Exception 클래스
- QueryDsl을 사용하기 위해 만들어진 Q Domain Entity 클래스까지 제외를 하였다.
Lombok Annotation 제외 방법
결론
후기
- 이로서 TDD(테스트 주도 개발), Unit Test(단위 테승트), Integration Test(통합 테스트), JaCoCo(테스트 커버리지)까지 공부하고 정리까지 끝냈다.
- 하면 할수록 배워야 할 부분이 너무 많았지만 습득하면 습득할수록 테스트에 대한 중요함을 많이 느끼게 된 계기가 되었다.
- 테스트 코드를 많이 작성하니 문서화 되는 것을 느낄수 있었고, 테스트 커버리지로 검증까지 끝난 지금에는 프로젝트에 품질이 많이 항샹된 것 느낄수가 있었다.
- 그리고 기능 개발, 리팩토링 하고 나서 중간중간 미리 짜두었던 테스트 코드를 돌려 확인하고 오류가 발생시에 다시 바로바로 수정이 가능하다는 점이 너무나도 좋았습니다.