1. 시작하며

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

테스트 커버리지(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 등으로 테스트 구조를 체계적으로 구성하는 것의 중요성
- 무엇보다 테스트 코드는 "많이 쓰는 것"보다 **"로직을 정확히 이해하고 검증하는 것"**이 더 중요하다는 점
앞으로도 "정확하게 커버된 테스트"를 목표로, 테스트 품질을 유지하고 개선해 나가고자 한다.
'Work' 카테고리의 다른 글
| Mockito로 static 메서드와 생성자(mockConstruction) 완전 제어하기 – 테스트 커버리지 100% 실전 전략 (2) | 2025.06.13 |
|---|---|
| Spring WebClient 체인 구조와 Mockito를 활용한 테스트 전략 정리 (4) | 2025.06.12 |
| 복합키 변경으로 다시 본 MyBatis와 쿼리 설계 – selectByExample을 선택하기까지 (2) | 2025.05.29 |
| 운영/로컬 환경에서 인증을 다르게 처리하는 법 (Spring Boot 실전 예시) (0) | 2025.05.27 |
| 트랜잭션 & 롤백 (Spring @Async 환경에서 @Transactional 롤백 실패 문제와 해결 과정) (0) | 2025.05.23 |