"In God we trust. All others must bring data." - W. Edwards Deming
(신을 제외하고는 모두 데이터를 가져와야 한다)
DungeonTalk 링크
테스트 커버리지 측정 목적
프로젝트를 진행하면서 단위 테스트를 진행했는데 실제로 얼마나 많은 코드가 실제로 테스트가 되고 있는지에 대한 명확한 답변을 할 수 없다는 결론을 내렸다.
측정 전 상황
•
테스트 코드는 작성했지만 커버리지는 측정하지 않음
•
어떤 부분이 테스트되지 않았는지 파악 불가
•
신규 기능 추가 시 기존 코드 영향도 확인이 어려움
⇒ 이러한 문제를 해결하고, 정량화된 품질 지표를 확보하기 위해
테스트 커버리지 측정 작업을 시작했다.
현재 테스트 현황 파악
1단계: 전체 테스트 수집 및 실행
우선 프로젝트에 얼마나 많은 테스트가 존재하는지 파악했다.
# 테스트 파일 개수 확인
find src/test -name "*Test.java" -o -name "*Tests.java" | wc -l
# 결과: 43개
# 전체 테스트 실행
./gradlew test
Java
복사
2단계: 도메인별 테스트 분석
테스트가 어떻게 분포되어 있는지 도메인별로 분석했다.
•
100% 성공 도메인 (203개 테스트)
도메인 | 테스트 수 | 성공률 | 비고 |
GameCharacter | 37개 | 100% | 게임 캐릭터 생성, 스탯 관리 |
Stat | 37개 | 100% | 능력치 계산 로직 |
Chat Controller | 14개 | 100% | REST API 엔드포인트 |
WebSocket | 12개 | 100% | 실시간 통신 |
Member | 10개 | 100% | 회원 관리 |
Matching | 10개 | 100% | 게임 매칭 로직 |
Chat Repository | 9개 | 100% | 데이터 접근 레이어 |
AiChat DTO/Entity | 8개 | 100% | 데이터 구조 검증 |
Auth Repository | 5개 | 100% | 인증 데이터 |
•
일부 실패 도메인 (16개 실패)
도메인 | 테스트 수 | 성공 | 실패 | 실패 원인 |
Chat Service | 34개 | 30개 | 4개 | Kafka 마이그레이션 |
Auth Service | 27개 | 24개 | 3개 | Mock 객체 미수정 |
AiChat Service | 21개 | 20개 | 1개 | 비동기 처리 변경 |
실패 원인:
•
Redis Pub/Sub → Kafka 전환 과정에서 Mock 객체가 RedisPublisher 기준으로 작성됨
•
KafkaPublisher로 변경했지만 테스트 코드는 미수정
•
빠른 배포를 위해 의도적으로 일부 테스트 수정을 다음 작업으로 연기
테스트 실행 시간 최적화:
1.
Mockito 활용: 외부 의존성을 Mock으로 대체하여 속도 향상
2.
H2 인메모리 DB: JPA Repository 테스트에서 PostgerSQL 대신 H2 사용
3.
TestContainers 선택적 사용: 통합 테스트가 필요한 경우에만 사용
JaCoCo 도입 및 커버리지 측정
JaCoCo란?
JaCoCo(Java Code Coverage)는 Java 코드의 커버리지를 측정하는 표준 도구이다.
어떤 코드가 테스트로 실행되었는지 라인 단위로 추적하여 리포트를 생성한다.
build.gradle 설정
plugins {
id 'jacoco'
}
jacoco {
toolVersion = "0.8.11"
}
jacocoTestReport {
dependsOn test
reports {
xml.required = true
html.required = true
html.outputLocation = layout.buildDirectory.dir('jacocoHtml')
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/config/**', // 설정 클래스 제외
'**/exception/**', // 예외 클래스 제외
'**/dto/**', // DTO 제외
'**/entity/**', // Entity 제외
'**/*Application.class' // 메인 클래스 제외
])
}))
}
}
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.70 // 최소 70% 커버리지 강제
}
}
}
}
Markdown
복사
테스트 제외 항목
기존에 작성되어 있던 Config, DTO, Entity 관련 테스트는 제외하기로 하였다.
핵심 원칙:
•
설정 코드나 데이터 구조는 테스트 대상이 아니다.
•
테스트 커버리지는 비즈니스 로직에 집중해야 한다.
•
Config 클래스: Spring Bean 설정만 담당, 비즈니스 로직 없음
•
DTO: Getter/Setter만 있는 단순 데이터 구조
•
Entity: JPA 엔티티는 DB 매핑만 담당, 로직은 Service에 위치
•
Application 클래스: main() 메서드만 있는 진입점
커버리지 측정 실행
# 테스트 실행 및 커버리지 측정
./gradlew test jacocoTestReport
# 커버리지 검증 (70% 미만 시 빌드 실패)
./gradlew jacocoTestCoverageVerification
Shell
복사
Layer별 커버리지 상세 분석
Repository Layer
•
테스트 전략:
◦
CRUD 기본 기능 테스트
◦
커스텀 쿼리 메서드 검증
◦
경계값 테스트 (빈 리스트, null 처리)
•
예시 테스트
@DataJpaTest
@Testcontainers
class ChatRoomRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
@DisplayName("채팅방 ID로 조회 성공")
void findByRoomId_Success() {
// Given
ChatRoom room = ChatRoom.builder()
.roomName("테스트방")
.maxCapacity(3)
.build();
chatRoomRepository.save(room);
// When
Optional<ChatRoom> found = chatRoomRepository.findById(room.getId());
// Then
assertThat(found).isPresent();
assertThat(found.get().getRoomName()).isEqualTo("테스트방");
}
@Test
@DisplayName("존재하지 않는 ID 조회 시 빈 Optional 반환")
void findByRoomId_NotFound() {
// When
Optional<ChatRoom> found = chatRoomRepository.findById("non-existent-id");
// Then
assertThat(found).isEmpty();
}
}
Java
복사
Testcontainers 활용 이유:
•
H2 DB는 PostgreSQL 고유 기능(UUID, JSON 타입 등) 미지원
•
프로덕션 환경과 동일한 PostgreSQL 컨테이너에서 테스트
•
실제 DB 동작 확인으로 신뢰도 향상
Service Layer
•
테스트 전략:
◦
핵심 비즈니스 로직 검증
◦
예외 상황 처리 테스트
◦
트랜잭션 동작 확인
•
예시 테스트
@ExtendWith(MockitoExtension.class)
class ChatMessageServiceTest {
@Mock
private ChatRoomRepository chatRoomRepository;
@Mock
private ChatMessageRepository chatMessageRepository;
@Mock
private KafkaPublisher kafkaPublisher;
@InjectMocks
private ChatMessageService chatMessageService;
@Test
@DisplayName("메시지 전송 성공 - Kafka 발행 확인")
void sendMessage_Success_KafkaPublished() throws Exception {
// Given
String roomId = "room-123";
ChatRoom room = ChatRoom.builder()
.id(roomId)
.roomName("테스트방")
.build();
when(chatRoomRepository.findById(roomId))
.thenReturn(Optional.of(room));
when(kafkaPublisher.publishChatAsync(any(), any()))
.thenReturn(CompletableFuture.completedFuture(null));
ChatMessageDto dto = ChatMessageDto.builder()
.roomId(roomId)
.content("안녕하세요")
.type(MessageType.TALK)
.build();
// When
chatMessageService.sendMessage(dto);
// Then
verify(chatMessageRepository).save(any(ChatMessage.class));
verify(kafkaPublisher).publishChatAsync(eq(roomId), any());
}
@Test
@DisplayName("존재하지 않는 채팅방에 메시지 전송 시 예외 발생")
void sendMessage_RoomNotFound_ThrowsException() {
// Given
when(chatRoomRepository.findById(any()))
.thenReturn(Optional.empty());
ChatMessageDto dto = ChatMessageDto.builder()
.roomId("invalid-room")
.content("메시지")
.build();
// When & Then
assertThatThrownBy(() -> chatMessageService.sendMessage(dto))
.isInstanceOf(ChatRoomNotFoundException.class)
.hasMessage("채팅방을 찾을 수 없습니다.");
}
}
Java
복사
•
Mockito 활용:
◦
외부 의존성(Repository, Kafka)을 Mock으로 대체
◦
비즈니스 로직만 집중 테스트
◦
테스트 속도 향상 (실제 DB, Kafka 불필요)
Controller Layer
•
테스트 전략:
◦
REST API 엔드포인트 동작 검증
◦
요청/응답 DTO 직렬화 확인
◦
HTTP 상태 코드 검증
◦
권한 및 예외 처리 테스트
•
예시 테스트
@WebMvcTest(ChatController.class)
class ChatControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ChatMessageService chatMessageService;
@Test
@DisplayName("채팅방 메시지 전송 API - 성공")
void sendMessage_Success() throws Exception {
// Given
String roomId = "room-123";
ChatMessageRequest request = new ChatMessageRequest("안녕하세요", "TALK");
// When & Then
mockMvc.perform(post("/v1/chatrooms/{roomId}/messages", roomId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
verify(chatMessageService).sendMessage(any(ChatMessageDto.class));
}
@Test
@DisplayName("잘못된 요청 형식 - 400 Bad Request")
void sendMessage_InvalidRequest_BadRequest() throws Exception {
// Given
String invalidJson = "{\"content\": \"\"}"; // content가 빈 문자열
// When & Then
mockMvc.perform(post("/v1/chatrooms/room-123/messages")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest());
}
}
Java
복사
테스트 품질 확보 전략
Arrange-Act-Assert (AAA) 패턴 준수
•
모든 테스트는 Given-When-Then 구조로 작성했다:
@Test
void testExample() {
// Given (준비): 테스트 데이터 설정
Member member = Member.builder()
.name("테스트유저")
.password("password123")
.build();
// When (실행): 테스트 대상 메서드 호출
memberService.register(member);
// Then (검증): 결과 확인
assertThat(memberRepository.findByName("테스트유저"))
.isPresent();
}
Java
복사
•
AAA 패턴의 장점:
◦
테스트 의도가 명확함
◦
가독성 향상 + 유지보수 용이
테스트 독립성 보장
•
각 테스트는 서로 영향을 주지 않도록 독립적으로 실행된다.
@BeforeEach
void setUp() {
// 각 테스트마다 새로운 Mock 객체 생성
MockitoAnnotations.openMocks(this);
// 테스트 데이터 초기화
testData = createTestData();
}
@AfterEach
void tearDown() {
// 테스트 후 정리 작업
repository.deleteAll();
}
Java
복사
의미 있는 테스트 메서드명
•
테스트 메서드명만 보고도 무엇을 테스트하는지 알 수 있도록 작성했다.
// ❌ 나쁜 예
@Test
void test1() { ... }
// ✅ 좋은 예
@Test
@DisplayName("채팅방 최대 인원 초과 시 입장 거부")
void joinChatRoom_ExceedMaxCapacity_ThrowsException() { ... }
Java
복사
•
네이밍 규칙:
◦
{메서드명}_{테스트조건}_{예상결과}
◦
@DisplayName으로 한글 설명 추가
측정 결과 및 성과
•
실제 측정 결과
전체 커버리지: 22%
- Missed Instructions: 11,509
- Covered Instructions: 3,366
- Total Instructions: 14,875
Java
복사
전반적으로 코어 도메인 중심으로 테스트를 진행하다보니
상대적으로 테스트 커버리지가 낮게 측정되지 않았나 싶다.
•
코어 도메인 중심 테스트 커버리지 확보
◦
TestContainers 활용 통합 테스트로 실제 DB 환경 재현
◦
JUnit 5, Mockito 기반 체계적 단위 테스트 작성
◦
43개 테스트 파일, 220개 테스트 케이스 구축
◦
전체 커버리지 22% (220개 테스트, 92.3% 성공률)
◦
핵심 비즈니스 로직 고품질 테스트:
▪
Stat 도메인: 95-100% (완벽한 테스트 커버리지)
▪
WebSocket 실시간 통신: 90%
▪
AiChat 핵심 로직: 97%
▪
Auth Service: 73%
▪
Chat 도메인: 50-80%
높은 커버리지 달성 패키지
패키지 | 커버리지 | 비고 |
org.com.dungeontalk.domain.stat.controller | 100% | |
org.com.dungeontalk.domain.aichat.common | 97% | |
org.com.dungeontalk.domain.stat.service | 95% | |
org.com.dungeontalk.global.websocket | 90% | |
org.com.dungeontalk.domain.chat.common | 80% | |
org.com.dungeontalk.domain.auth.service | 73% | 양호 |
org.com.dungeontalk.domain.chat.controller | 73% | 양호 |
org.com.dungeontalk.domain.member.service | 67% | 보통 |
org.com.dungeontalk.domain.auth.manager | 66% | 보통 |
org.com.dungeontalk.domain.chat.validation.message | 64% | 보통 |
org.com.dungeontalk.domain.gamecharacter.service | 60% | 보통 |
낮은 커버리지 패키지
패키지 | 커버리지 | 문제점 |
org.com.dungeontalk.domain.chat.service | 50% | |
org.com.dungeontalk.domain.rsData | 40% | |
org.com.dungeontalk.domain.gamecharacter.controller | 27% | |
org.com.dungeontalk.domain.auth.controller | 13% | |
org.com.dungeontalk.domain.aichat.service | 13% | |
org.com.dungeontalk.domain.room.service | 6% | |
org.com.dungeontalk.domain.room.common | 7% | |
org.com.dungeontalk.global.security | 4% | |
org.com.dungeontalk.global.util | 10% |
0% 커버리지 패키지 (테스트 없음)
•
org.com.dungeontalk.domain.matching.service
•
org.com.dungeontalk.global.filter
•
org.com.dungeontalk.global.redis
•
org.com.dungeontalk.global.kafka
•
org.com.dungeontalk.domain.aichat.util
•
org.com.dungeontalk.domain.aichat.controller
•
org.com.dungeontalk.domain.worldtype.service
•
org.com.dungeontalk.domain.worldtype.controller
•
org.com.dungeontalk.domain.room.controller
•
org.com.dungeontalk.domain.matching.controller
•
org.com.dungeontalk.domain.matching.factory
•
org.com.dungeontalk.domain.matching.util
•
org.com.dungeontalk.domain.matching.common
•
org.com.dungeontalk.domain.chat.listener
•
org.com.dungeontalk.web.controller
•
org.com.dungeontalk.global.aop.cacheAop
•
org.com.dungeontalk.domain.world.service
•
org.com.dungeontalk.domain.world.controller
•
org.com.dungeontalk.domain.chat.util
테스트 실행 결과
Total Tests: 220개
Passed: 203개 (92.3%)
Failed: 17개 (7.7%)
Java
복사
22%가 나온 이유
•
Config/DTO/Entity 제외 설정이 작동하지 않은 것으로 판단
•
build.gradle 에서 다음을 제외한다고 설정했지만 실제로는 모든 클래스가 포함되어 측정됨.
exclude: [
'**/config/**',
'**/exception/**',
'**/dto/**',
'**/entity/**',
'**/*Application.class'
]
Markdown
복사
많은 도메인이 테스트 미작성
•
Matching 도메인: 완전 미작성 (0%)
•
WorldType 도메인: 완전 미작성 (0%)
•
World 도메인: 완전 미작성 (0%)
•
Kafka, Redis 인프라: 완전 미작성 (0%)
Controller Layer 테스트 부족
일부 Controller만 테스트 작성:
•
ChatController: 73% (테스트 있음)
•
StatController: 100% (테스트 있음)
•
AuthController: 13%, GameCharacterController: 27%, 기타 대부분 0%
테스트 잘 된 영역
1.
Stat 도메인: 95-100% (우수)
2.
WebSocket: 90% (우수)
3.
AiChat Common: 97% (우수)
4.
Chat Common: 80% (양호)
5.
Auth Service: 73% (양호)
테스트가 부족한 영역
1.
Matching 도메인 전체: 0%
2.
World/WorldType 도메인: 0%
3.
인프라 레이어 (Filter, Redis, Kafka): 0%
4.
많은 Controller: 0-27%
5.
AiChat Service: 13%
6.
Room Service: 6%
테스트 커버리지 측정 후 느낀 점
측정 결과에 대해 만족할 수 없었다. 테스트 코드 작성을 혼자 담당하여 작성하다 보니 여러 가지로 어려운 부분이 한 두 가지가 아니었다. 각 도메인별 기능이 어떻게 동작하는지를 파악해야 했고 해당 기능들의 예외 처리나 여러 도메인과 엮여있는 기능들을 분석하는데도 많은 시간이 걸렸다.
더군다나 게임 도메인을 각자 처음 접하다 보니 프로젝트 진행 말미에도 코드 구조와 스타일을 수정하는 경우가 종종 생겼고 그 과정에서 기존에 작성한 테스트 코드가 제대로 동작이 안 되는 이슈도 있었다. 일단 핵심 비즈니스 로직에 집중하여 테스트를 진행하기로 하였고 그 결과 Stat 도메인 95-100%, WebSocket 90%, AiChat 97% 커버리지를 달성할 수 있었다. TestContainers로 실제 DB 환경에서 검증하여 서비스 품질 확보를 위해 노력했다.
비록 테스트 커버리지 결과 수치가 낮게 나와 아쉬웠지만 JaCoCo를 활용하여 테스트 커버리지를 측정한 과정이 굉장히 의미가 있다고 생각한다.



