본문 바로가기
Work

JUnit5 + Mockito로 Java 테스트 커버리지 100% 달성하는 법 (실전 사례 코드 포함)

by devjapan 2025. 6. 9.
728x90

1. 시작하며

https://medium.com/@bubu.tripathy/junit5-assumptions-d67d8cb6c28d

요즘 내 주요 업무는 테스트 커버리지를 100%까지 끌어올리는 것이다. 단순히 테스트 코드를 “적당히” 추가하는 수준이 아니라, 정말 모든 분기와 예외 흐름까지 다 커버하는 테스트를 작성해야 했다. 심지어 클래스 내부는 수정할 수 없고, 테스트 코드만으로 커버리지를 맞춰야 했다. 처음엔 막막했지만, 하나씩 부딪히면서 쌓은 노하우들을 정리해본다. 스터디나 동료에게 설명할 때도 쓰고, 블로그 공유용으로도 활용할 수 있도록 작성했다.


2. 테스트 커버리지란?

https://testsigma.com/blog/test-coverage/

 

테스트 커버리지(Test Coverage)는 “작성한 테스트 코드가 실제 프로덕션 코드를 얼마나 실행했는가”를 측정하는 지표이다. 커버리지를 통해 테스트가 로직 전체를 충분히 검증하고 있는지를 수치로 확인할 수 있다.

 

📊 대표적인 커버리지 기준

종류 설명
Line Coverage 전체 코드 줄 중 실행된 줄의 비율
Branch Coverage 조건 분기(if, switch 등)의 경우의 수 중 실행된 비율
Method Coverage 전체 메서드 중 호출된 메서드의 비율
Instruction Coverage JVM 바이트코드 명령 단위로 측정한 커버리지

3. Jacoco란?

Java Code Coverage의 약자
→ Java 프로젝트에서 테스트 커버리지를 분석하는 가장 많이 사용되는 오픈소스 도구

 

📌 주요 특징:

  • JVM 바이트코드 분석 기반 – 실제 실행된 경로를 정확히 추적
  • Gradle, Maven, Ant 등 다양한 빌드 툴과 쉽게 통합 가능
  • HTML, XML, CSV 포맷 리포트 제공
  • IntelliJ, Eclipse 플러그인에서도 결과 확인 가능

 

📁 HTML 리포트 예시:

  • 초록색: 실행된 코드
  • 노란색: 일부 실행된 조건 분기
  • 빨간색: 테스트에서 실행되지 않은 코드

 

⚒️예시 Gradle 설정:

jacocoTestReport {
    reports {
        html.required.set(true)
        xml.required.set(false)
        csv.required.set(false)
    }
}

 

🎯 커버리지 100%란?

Jacoco 기준으로 라인 커버리지와 분기 커버리지가 모두 100%인 상태를 목표로 작업하였다.
단순히 테스트를 많이 쓰는 것이 아니라, 실제로 어떤 분기, 예외, 조건문까지 다 통과했는가가 중요하다.


4. 내가 맡은 테스트 조건

  • 클래스 구현부 수정 금지 (즉, 내부 구조 그대로 테스트해야 함)
  • protected, private 필드 또는 메서드 다수 존재
  • 외부 의존성 (Logger, DAO, Properties) 많음
  • 테스트 코드만으로 모든 실행 경로 커버해야 함
  • 목적: 라인 커버리지 + 분기 커버리지 모두 100% (부득이한 경우엔 100% 아니어도 괜찮지만 그 이유 서술)

5. 커버리지를 채운 실제 사례별 전략

테스트 커버리지 100%를 만들기 위해, 클래스의 성격에 따라 각각 다른 방식으로 접근해야 했다. 이 섹션에서는 내가 실제로 작성한 테스트 코드와 전략을 유형별로 정리한다.

 

① Enum 처리 클래스 – EnumValueTypeHandler

문제: MyBatis TypeHandler 내에서 Enum.valueOf() 호출 → 정의되지 않은 값에 대한 분기 존재

전략:

  • 유효한 enum 값들에 대해 정상 매핑 확인
  • null 또는 잘못된 값 전달 → IllegalArgumentException 분기 커버
@Test
void setParameter_withInvalidEnum_shouldThrowException() {
    assertThrows(IllegalArgumentException.class, () ->
        handler.setParameter(ps, 1, null, JdbcType.VARCHAR)
    );
}

@Test
void getResult_withUnknownValue_shouldThrowException() throws SQLException {
    when(rs.getString("column")).thenReturn("UNKNOWN");
    assertThrows(IllegalArgumentException.class, () -> handler.getResult(rs, "column"));
}

 

② JSON 변환 로직 – FileUploadServiceImpl의 createPolicyJson

문제: 내부에서 new JsonMapper().writeValueAsString(...) → JsonProcessingException 발생 여부 테스트 어려움

전략:

  • null key를 포함한 Map 전달 → Jackson 예외 유도
  • 리플렉션으로 private 메서드 invoke
@Test
void createPolicyJson_JsonProcessingExceptionが発生すること() throws Exception {
    Method method = FileUploadServiceImpl.class.getDeclaredMethod(
        "createPolicyJson", Instant.class, Map.class, int.class, Long.class
    );
    method.setAccessible(true);

    Map<String, String> map = new HashMap<>();
    map.put(null, "value");

    InvocationTargetException ex = assertThrows(InvocationTargetException.class, () -> {
        method.invoke(service, Instant.now(), map, 0, 100L);
    });

    assertThat(ex.getCause()).isInstanceOf(RuntimeException.class);
    assertThat(ex.getCause().getCause()).isInstanceOf(JsonProcessingException.class);
}

 

 

③ Abstract 클래스 – AbstractAnalysisLogService

문제: 대부분 protected 메서드이며, analysisLogDao, envProperties 등 필드 다수 존재

전략:

  • 테스트 대상 메서드는 익명 서브 클래스로 override
  • 필드는 리플렉션으로 mock 주입
  • 호출 여부는 verify(dao).save(...) 등으로 검증
@BeforeEach
void setUp() throws Exception {
    service = new AbstractAnalysisLogService() {
        @Override protected void putSearchLog(...) { called = true; }
    };
    setField("analysisLogDao", mockDao);
}

@Test
void putSearchLog_shouldSaveLog() {
    service.putSearchLog("type", "id", context);
    verify(mockDao).insert(any());
}

 

④ Elasticsearch DAO – AbstractElasticsearchDao

문제: faultToleranceDao.decorate(...)로 래핑된 client.search(...) 실행 → 분기 및 예외 흐름 많음

전략:

  • decorate()에서 그대로 Callable 반환
  • client.search() 호출 여부 검증
  • 예외 던지는 Callable로 catch 흐름 유도
@Test
void test_search_success() throws Exception {
    when(faultToleranceDao.decorate(...)).thenAnswer(inv -> inv.getArgument(2)); // Callable 그대로
    when(client.search(...)).thenReturn(mockResponse);

    SearchResponse result = dao.search(String.class, (b, i) -> b.index(i));
    assertNotNull(result);
}

@Test
void test_search_throwsUnexpectedException() throws Exception {
    when(faultToleranceDao.decorate(...)).thenReturn(() -> { throw new NullPointerException(); });

    RuntimeException ex = assertThrows(RuntimeException.class, () -> {
        dao.search(String.class, (b, i) -> b.index(i));
    });
    assertTrue(ex.getCause() instanceof NullPointerException);
}

 

⑤ static 메서드 사용 – ZonedDateTimeHandler의 ZonedDateTimeModule 의존

전략:

  • mockStatic() 사용
  • ZonedDateTime.now() 같은 메서드 결과 고정
@Test
void test_customZonedDateTimeSerialization() {
    try (MockedStatic<ZonedDateTimeModule> mocked = mockStatic(ZonedDateTimeModule.class)) {
        mocked.when(ZonedDateTimeModule::getZoneId).thenReturn(ZoneId.of("Asia/Seoul"));
        ...
    }
}

 

⑥ MicrosoftGraphDaoImpl – 의존성 많고 분기 복잡

전략:

  • 토큰 조회, 응답 status 확인 등 모든 분기 테스트
  • 401/403/500 등 다양한 status에 대한 분기 작성
@Test
void sendRequest_unauthorized_shouldThrow() {
    when(restTemplate.exchange(...)).thenReturn(ResponseEntity.status(401).build());

    assertThrows(GraphException.class, () -> {
        graphDao.sendRequest(...);
    });
}

 

⑦ Logger만 있는 메서드

전략:

  • Logger를 mock으로 교체
  • 로그 호출 여부 검증
@Test
void logMessage_shouldLog() {
    Logger logger = mock(Logger.class);
    setField(service, "logger", logger);

    service.logMessage("hello");
    verify(logger).info(contains("hello"));
}

 

 

✅ 유형별 요약 정리

유형 요약
Enum 처리 전 enum 값 + 잘못된 값 테스트, IllegalArgumentException 확인
JSON 변환 (ObjectMapper) null key, 리플렉션 활용해 예외 유도
추상 클래스 (Abstract) 익명 서브 클래스 + 필드 리플렉션 주입 + verify
외부 의존성 (Elasticsearch, RestTemplate) mock/stub + 예외 흐름 포함
static 유틸 mockStatic() 사용 또는 결과 고정
Logger 전용 메서드 Logger mock 후 호출 여부 verify()

6. 테스트 코드 구조화 – @Nested와 @DisplayName

커버리지를 채우다 보면 테스트 클래스에 수십 개의 테스트가 생기기 쉽다. 이런 테스트들이 엉켜 보이지 않도록 정리하는 방법이 바로 @Nested와 @DisplayName이다.

 

1) @Nested – 테스트를 상황별로 그룹화

JUnit5의 @Nested를 사용하면 테스트 클래스 내에서 서브 클래스를 정의하여 상황/기능 단위로 테스트 그룹화할 수 있다.

@Nested
@DisplayName("createPolicyJson 메서드 테스트")
class CreatePolicyJsonTest {

    @Test
    @DisplayName("정상적으로 JSON이 생성되는 경우")
    void success() { ... }

    @Test
    @DisplayName("JsonProcessingException이 발생하는 경우")
    void error() { ... }
}
  • 테스트 클래스가 커져도 기능별로 정리
  • 테스트 리포트에서 계층 구조로 표시되어 가독성 향상

 

2) @ DisplayName – 읽기 쉬운 테스트명 제공

@DisplayName은 테스트 메서드 이름을 사람이 읽기 쉽게 바꿔주는 역할을 한다. 한글이나 일본어도 가능해서 리포트 문서화, 발표용, 리뷰용으로 매우 유용하다.

@Test
@DisplayName("null 값이 들어오면 IllegalArgumentException 발생")
void testNullInput() {
    ...
}
  • 테스트 리포트에서 “메서드명이 아닌 설명문 형태”로 표시됨
  • 한글/일본어도 사용 가능해서 문서용/스터디용 리포트에 유리

 

📌 왜 사용하는가?

기능 설명 추천 이유
@Nested 테스트 그룹화 (메서드, 케이스별) 테스트가 많아질수록 가독성과 유지보수 향상
@DisplayName 사람이 읽기 쉬운 테스트 이름 테스트 리포트, 문서화, 발표용으로 매우 유용

7. 특수 케이스 – 테스트가 불가능했던 구조적 제약 및 접근 제약

테스트 커버리지를 100%로 끌어올리는 과정에서, Mockito의 기술적 한계나 코드 구조상 제약으로 인해 사실상 테스트가 불가능한 분기들이 존재한다. 이들은 실제 실행 환경에서는 거의 발생하지 않거나, 테스트 환경에서는 예외를 의도적으로 유도할 수 없는 구조이다. 대표적으로 다음 두 가지 상황이 있었다.

 

1) new JsonMapper() 사용 → Mockito로 stub 불가 (구조적 제약)

 

 ‼️문제점

JsonMapper objectMapper = new JsonMapper();
try {
    return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
    throw new RuntimeException(e);
}
  • JsonMapper 객체를 new로 직접 생성하고 있어, Mockito로 mock 또는 stub이 불가능
  • 따라서 writeValueAsString()에서 예외를 던지도록 만드는 것이 불가능
  • 이로 인해 catch (JsonProcessingException e) 분기를 테스트로 커버할 수 없음

 

 🧪 시도한 해결책: 예외 유도 입력

  • Jackson은 null key가 포함된 Map을 직렬화하려고 할 때 JsonProcessingException을 발생시킬 수 있음
  • 이를 이용해 다음과 같은 입력으로 예외를 유도하는 테스트를 작성:
Map<String, String> invalidMap = new HashMap<>();
invalidMap.put(null, "value");

→ 그러나 이 방식은 JVM 환경, Jackson 버전 등에 따라 예외가 발생하지 않을 수도 있어, 테스트 신뢰성이 낮음

 

✅ 결론

  • Mockito로 내부 생성 객체를 조작할 수 없고
  • 입력 기반 예외 유도도 환경 의존성이 커서 불안정함
  • 따라서 해당 분기는 구조상 테스트 불가능한 영역으로 판단하고 커버리지 제외 사유로 기록

 

2) createPolicyJson() 메서드가 private → 테스트 접근 불가 (접근 제약)

테스트 대상인 createPolicyJson() 메서드가 private 접근 제한자이기 때문에, 테스트 코드에서 직접 호출할 수 없는 문제가 있었다.

 

‼️문제점

private String createPolicyJson(Instant expirationTime, Map<String, String> conditions, int fileLimit, Long maxSize) {
    JsonMapper objectMapper = new JsonMapper();
    try {
        Map<String, Object> policy = new HashMap<>();
        policy.put("expiration", expirationTime.toString());
        policy.put("conditions", conditions);
        return objectMapper.writeValueAsString(policy);
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
}
  • 위 메서드는 private으로 선언되어 있어 일반적인 단위 테스트 코드에서 직접 호출이 불가능
  • mock 문제가 아닌 접근성 자체의 문제

 🧪 시도한 해결책: 예외 유도 입력

Method method = FileUploadServiceImpl.class.getDeclaredMethod(
    "createPolicyJson", Instant.class, Map.class, int.class, Long.class
);
method.setAccessible(true); // private 접근 우회

Map<String, String> invalidMap = new HashMap<>();
invalidMap.put(null, "value"); // 예외 유도용 입력

InvocationTargetException ex = assertThrows(InvocationTargetException.class, () -> {
    method.invoke(service, Instant.now(), invalidMap, 0, 100L);
});

 

✅ 결론

  • Reflection을 통해 private 메서드를 호출하는 데에는 성공했지만
  • 내부에서 예외가 항상 발생하는 것이 아니기 때문에 (JVM 환경, Jackson 동작에 따라 다름)
  • 테스트 결과가 불확실하고 비결정적
  • 결국 이 분기도 커버리지는 제외 대상으로 명시

 

📌 예시 설명 (PR 또는 발표용 문구)

JsonMapper가 메서드 내부에서 직접 생성되는 구조이기 때문에 Mockito로 mock/stub이 불가능하며, writeValueAsString()에서 예외를 유도하는 테스트 역시 환경 의존성이 높아 현실적으로 어렵습니다.
또한 대상 메서드가 private이므로 테스트 코드에서 직접 호출할 수 없어 Reflection API를 사용해 강제 호출하였으나, 예외 유도가 불안정하여 커버리지는 제외 처리하였습니다.

 

 

🔍 기타 테스트가 어려운 케이스 정리

케이스 설명 대응 방식
IllegalAccessException setAccessible(true) 사용 시 발생 안 함 강제로 false 설정하여 유도 가능 (비현실적)
static final 상수 분기 상수는 변경 불가 → 특정 분기 진입 불가 설계 개선이 불가능하다면 테스트 제외
단순 로깅 처리 로직 없이 로그만 출력되는 분기 Logger mock을 통한 호출 여부만 검증 가능
 

💡 요약

내용 처리방식
Mockito로 new로 생성된 객체는 stub 불가 구조 변경 없으면 예외 커버리지는 포기 또는 입력 기반 예외 유도 시도
private 메서드 접근 불가 Reflection API를 통해 강제 호출
커버리지 100%가 논리적으로 불가능한 경우 리뷰/PR에서 기술적 제약을 명시하고 제외 처리

8. 커버리지 100% 체크리스트 

✔️ 모든 조건문의 true/false 커버

✔️ switch의 모든 case + default 실행 여부 확인

✔️ try-catch에서 예외 발생 흐름 포함

✔️ null 또는 잘못된 값 처리 여부

✔️ 반복문 내 조건 분기 커버

✔️ 단순 로그 출력 부분도 검증 여부 확인

✔️ Jacoco 리포트 기준 빨간 줄 없는지 수동 점검


9. 마무리하며

이번 작업을 통해 단순히 커버리지 수치를 맞추는 것을 넘어서 테스트 코드가 정말 신뢰할 수 있는 방어망이 되기 위해 어떤 점들을 고려해야 하는지를 배웠다.

특히 얻은 인사이트는 다음과 같다:

  • 리플렉션, mockStatic, 메서드 주입 등 다양한 테스트 테크닉의 실전 활용
  • 단순 예외가 아닌 구조적으로 불가능한 케이스를 판단하고, 리뷰나 문서에 명시하는 습관
  • @Nested, @DisplayName 등으로 테스트 구조를 체계적으로 구성하는 것의 중요성
  • 무엇보다 테스트 코드는 "많이 쓰는 것"보다 **"로직을 정확히 이해하고 검증하는 것"**이 더 중요하다는 점

앞으로도 "정확하게 커버된 테스트"를 목표로, 테스트 품질을 유지하고 개선해 나가고자 한다.

 

728x90