MSA Phase 6. TDD(3) - JaCoCo

devty·2023년 9월 24일
0

MSA

목록 보기
9/14

서론

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 등과 잘 통합
커스텀 리포트 생성어려움가능
다양한 빌드 도구와의 통합제한적넓은 범위의 빌드 도구 지원
커뮤니티 지원제한적활발한 오픈소스 커뮤니티 지원
  1. JaCoCo는 더 다양한 코드 커버리지 지표를 제공하여, 높은 수준의 분석을 가능하게 한다.
  2. JaCoCo는 CI/CD 파이프라인과 통합이 쉬워, 자동화된 환경에서의 테스트 커버리지 분석 및 보고에 유용하다.
  3. 여러 프로젝트나 팀 간에 동일한 테스트 커버리지 도구를 사용하면 분석 결과의 일관성을 유지할 수 있다.
  4. JaCoCo는 다양한 형식의 리포트를 생성할 수 있어, 팀의 필요에 따라 맞춤화된 리포트를 생성할 수 있다.
  • 위와 같은 이유로 인텔리제이에 테스트 커버리지보단 JaCoCo에 테스트 커버리지를 사용하게 되었다.

JaCoCO 플러그인 추가

plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = '0.8.8'

//  테스트결과 리포트를 저장할 경로 변경
//  default는 "$/jacoco"
//  reportsDir = file("$buildDir/저장할 경로 지정")
}
  • 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() {
            // given
            when(checkUsernameQuery.checkUsername(testUsername)).thenReturn(null);
    
            // when
            ResponseEntity<ReturnObject> response = userCheckController.checkUsername(testUsername);
    
            // then
            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가지이다.
    1. Task :user-service:test
    2. Task :user-service:jacocoTestReport
    3. 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 제외 방법

  • lombok.config
    lombok.addLombokGeneratedAnnotation = true
    • 해당 파일을 root에 두면된다.
    • Lombok Annotation으로 사용했던 Getter, Setter 등이 테스트 범위로 잡혀서 제외해주기위해 만들었다.

결론

후기

  • 이로서 TDD(테스트 주도 개발), Unit Test(단위 테승트), Integration Test(통합 테스트), JaCoCo(테스트 커버리지)까지 공부하고 정리까지 끝냈다.
  • 하면 할수록 배워야 할 부분이 너무 많았지만 습득하면 습득할수록 테스트에 대한 중요함을 많이 느끼게 된 계기가 되었다.
  • 테스트 코드를 많이 작성하니 문서화 되는 것을 느낄수 있었고, 테스트 커버리지로 검증까지 끝난 지금에는 프로젝트에 품질이 많이 항샹된 것 느낄수가 있었다.
  • 그리고 기능 개발, 리팩토링 하고 나서 중간중간 미리 짜두었던 테스트 코드를 돌려 확인하고 오류가 발생시에 다시 바로바로 수정이 가능하다는 점이 너무나도 좋았습니다.
profile
지나가는 개발자

0개의 댓글