프로젝트/Ku:room

Jacoco를 이용한 테스트 커버리지 측정후, 보완하기

개발하는 민우 2025. 2. 24. 23:58

왜 적용했는가?

현재 테스팅을 통해 컨트롤러, 서비스, 도메인, 레포지토리 단에서 코드를 짜고 있다.

내 테스트 코드가 얼마나 실제 도메인을 커버를 하고 있는지, 좋은 테스트를 짜고 있는지 궁금하여 Jacoco를 도입하였다.

 

Jacoco란?

JaCoCo는 단위 테스트 또는 통합 테스트를 실행하면서 어떤 코드가 실행되었는지 분석하고 커버리지 리포트를 생성해준다. 이를 통해 테스트가 충분히 작성되었는지 확인하고, 테스트 누락된 부분을 보완할 수 있다.

 

Build.gradle

plugins {
	id 'jacoco'
}

jacoco {
	toolVersion = "0.8.8"
	reportsDirectory = layout.buildDirectory.dir('customJacocoReportDir')
}

test {
	finalizedBy jacocoTestReport // test 작업이 끝나고 jacocoTestReport를 실행
}

jacocoTestReport {
	dependsOn test // test 종속성 추가

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

	def QDomainList = []
	for (qPattern in '**/QA'..'**/QZ') { // QClass 대응
		QDomainList.add(qPattern + '*')
	}

	afterEvaluate {
		classDirectories.setFrom(files(classDirectories.files.collect {
			fileTree(dir: it, exclude: [
					'**/dto/**',
					'**/event/**',
					'**/*InitData*',
					'**/*Application*',
					'**/exception/**',
					'**/service/alarm/**',
					'**/aop/**',
					'**/config/**',
					'**/MemberRole*'
			] + QDomainList)
		}))
	}

	finalizedBy 'jacocoTestCoverageVerification' // jacocoTestReport 태스크가 끝난 후 실행
}

jacocoTestCoverageVerification {
	violationRules {

		rule {
			enabled = true
			//코드 버커리지 체크 기준
			element = 'CLASS'

			limit {
				counter = 'METHOD'
				value = 'COVEREDRATIO'
				minimum = 0.5
			}
		}
	}
}

 

다음과 같은 Build.gradle을 이용하여 dto, event, exception 같이 테스트 커버리지 측정이 불필요한 패키지를 분리하고, 최소 테스트 커버리지 비율을 조정할 수 있다.

 

테스트 실행 후,
build/reports/jacoco/test/html/index.html를 실행하면, 테스트 커버리지를 측정가능하다.

 

현 테스트 커버리지

테스트 커버리지가 reservation application, oauth, friendApplication 등 0%부터 33%까지 50%를 넘지 못하는 클래스가 존재한다.

따라서 테스트 커버리지가 부족한 테스트 코드를 작성해준다.

 

현재 내가 진행한 LoggingInterceptor, RequestBodyWrapperFilter의 테스트 커버리지이다.

테스트 커버리지 보완

global/log/LoggingInterceptorTest 패키지를 만들어, 로깅에 대한 단위 테스트를 진행하였다.

 

테스트 코드(LoggingInterceptorTest.class)

@DataJpaTest
@AutoConfigureMockMvc(addFilters = false)
class LoggingInterceptorTest {

    @InjectMocks
    private LoggingInterceptor loggingInterceptor;

    @Mock
    private ApiLogRepository apiLogRepository;

    @Mock
    private ObjectMapper objectMapper;

    @Test
    void afterCompletion_정상적인_요청이면_로그가_저장된다() throws Exception {
        // Given
        ContentCachingRequestWrapper cachingRequest = mock(ContentCachingRequestWrapper.class); // 실제로 ContentCachingRequestWrapper를 mock
        HttpServletResponse response = mock(HttpServletResponse.class);
        Object handler = mock(Object.class);

        // Mock behavior for ContentCachingRequestWrapper
        given(cachingRequest.getMethod()).willReturn("POST");
        given(cachingRequest.getRequestURI()).willReturn("/api/v1/users");
        given(cachingRequest.getHeader(HttpHeaders.AUTHORIZATION)).willReturn("Bearer token");
        given(cachingRequest.getHeader("X-Forwarded-For")).willReturn("192.168.1.1");

        byte[] content = "{\"test\":\"@konkuk.ac.kr\",\"loginId\":\"test123\",\"password\":\"test123\",\"studentId\":\"202112322\",\"department\":\"컴퓨터공학부\",\"nickname\":\"미미미누\"}".getBytes(StandardCharsets.UTF_8);
        given(cachingRequest.getContentAsByteArray()).willReturn(content);  // Mock the body content

        given(objectMapper.readTree(content)).willReturn(new ObjectNode(JsonNodeFactory.instance));

        // When
        loggingInterceptor.afterCompletion(cachingRequest, response, handler, null);

        // Then
        ArgumentCaptor<ApiLog> logCaptor = ArgumentCaptor.forClass(ApiLog.class);
        verify(apiLogRepository, times(1)).save(logCaptor.capture());

        ApiLog savedLog = logCaptor.getValue();
        assertThat(savedLog.getHttpMethod()).isEqualTo("POST");
        assertThat(savedLog.getRequestURI()).isEqualTo("/api/v1/users");
        assertThat(savedLog.isAccessTokenExist()).isTrue();
        assertThat(savedLog.getRequestIP()).isEqualTo("192.168.1.1");
    }

    @Test
    void afterCompletion_요청본문이_없어도_예외없이_동작한다() throws Exception {
        // Given
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setMethod("GET");
        request.setRequestURI("/test/no-body");

        MockHttpServletResponse response = new MockHttpServletResponse();
        ContentCachingRequestWrapper cachingRequest = new ContentCachingRequestWrapper(request);

        when(objectMapper.readTree(any(byte[].class))).thenReturn(null);

        // When
        loggingInterceptor.afterCompletion(cachingRequest, response, null, null);

        // Then
        verify(apiLogRepository, times(1)).save(any(ApiLog.class));
    }
}

 

1. @DataJpaTest 를 통해 JPA에 관리한 필요한 빈만 연동하는, 단위 테스트를 진행하였다.

2. ObjectMapper, ApiLogRepository를 Mock 객체로 주입하였다.

3. 정상적인 요청이면 로그에 저장하는 로직, 요청 본문이 없어도 예외없이 동작하는 로직을 테스트하였다.

 

테스트 코드 이후 커버리지

 

global.log.domain -> 85%, global.log -> 90%로 테스트 커버리지가 증가하였다.

LoggingInterceptor의 커버리지가 100%로 증가하였다.

 

보완해야 할점

테스트 커버리지에 집중하는 것도 중요하지만, 경계값 테스트 등을 정성스럽게 진행하여 테스트 코드가 실제로 테스팅하는지 판단하는게 중요하다. 실제로 이 코드에서도 RequestBody가 정상적으로 읽히는지 테스트하고 있지 않다. 이 부분을 보완해야 겠다.