TRPG 게임 백엔드를 개발하면서 매번 기능을 추가할 때마다 이런 생각이 들었습니다.
"이 코드 바꿔도 다른 기능 안 망가질까?"
"MongoDB 연동이 제대로 되는지 어떻게 확인하지?"
"서비스 로직이 복잡해지는데 테스트는 어떻게 하지?"
그래서 테스트 코드를 시작하기로 했습니다. 하지만 막상 시작하려니...
어디서부터 시작해야 할지 모르겠더라고요.
초보자인 저는 간단한 것부터 시작하기로 했습니다.
@Test
@DisplayName("Builder로 객체 생성 테스트")
void builderCreatesObject() {
// given
String memberId = "user-12345";
WorldType worldType = WorldType.FANTASY;
// when
AiServiceRequest result = AiServiceRequest.builder()
.memberId(memberId)
.worldType(worldType)
.build();
// then
assertThat(result).isNotNull();
assertThat(result.getMemberId()).isEqualTo(memberId);
assertThat(result.getWorldType()).isEqualTo(worldType);
}
@Test
@DisplayName("JSON 직렬화/역직렬화 테스트")
void serializeToJson() throws Exception {
// given
AiServiceRequest request = AiServiceRequest.builder()
.memberId("test-user")
.worldType(WorldType.FANTASY)
.build();
// when
String json = objectMapper.writeValueAsString(request);
AiServiceRequest result = objectMapper.readValue(json, AiServiceRequest.class);
// then
assertThat(json).contains("\"memberId\":\"test-user\"");
assertThat(result.getMemberId()).isEqualTo("test-user");
}
왜 DTO부터했을까?
진짜 도전은 Repository 테스트였습니다. Mock vs 실제 DB를 놓고 고민했는데...
"MongoDB 연동이 제대로 되는지 확인하려면 실제 DB를 써야겠다!"
그래서 TestContainers를 선택했습니다.

실제로 켜진 모습도 확인이 가능합니다
@Testcontainers
@DataMongoTest
@ActiveProfiles("test")
class AiGameMessageRepositoryTest {
@Container
static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl);
}
@Autowired
AiGameMessageRepository repository;
@Test
@DisplayName("메시지 저장 후 조회 테스트")
void saveAndFindMessage() {
// given
AiGameMessage message = AiGameMessage.builder()
.aiGameRoomId("room-123")
.content("테스트 메시지")
.messageType(AiMessageType.USER)
.turnNumber(1)
.messageOrder(1)
.createdAt(Instant.now())
.build();
// when
AiGameMessage saved = repository.save(message);
Optional<AiGameMessage> found = repository.findById(saved.getId());
// then
assertThat(found).isPresent();
assertThat(found.get().getContent()).isEqualTo("테스트 메시지");
assertThat(found.get().getMessageType()).isEqualTo(AiMessageType.USER);
}
@Test
@DisplayName("룸별 메시지 조회 테스트")
void findByAiGameRoomIdOrderByCreatedAt() {
// given
String roomId = "room-456";
AiGameMessage message1 = createTestMessage(roomId, "첫 번째 메시지", 1);
AiGameMessage message2 = createTestMessage(roomId, "두 번째 메시지", 2);
repository.saveAll(List.of(message1, message2));
// when
List<AiGameMessage> messages = repository.findByAiGameRoomIdOrderByCreatedAt(roomId);
// then
assertThat(messages).hasSize(2);
assertThat(messages.get(0).getContent()).isEqualTo("첫 번째 메시지");
assertThat(messages.get(1).getContent()).isEqualTo("두 번째 메시지");
}
}
TestContainers의 장점:
삽질했던 부분:
@DynamicPropertySource 없이 했다가 connection 실패Service 테스트는 다른 접근이 필요했습니다. 모든 의존성을 실제로 띄우기엔 너무 무거워서 Mock을 사용했습니다.
@ExtendWith(MockitoExtension.class)
class AiGameMessageServiceTest {
@Mock
AiGameMessageRepository aiGameMessageRepository;
@Mock
RedisPublisher redisPublisher;
@Mock
ProfanityFilterService profanityFilterService;
@Mock
AiGameValidator aiGameValidator;
@InjectMocks
AiGameMessageService aiGameMessageService;
@Test
@DisplayName("AI 메시지 저장 성공 테스트")
void saveAiMessage_success() {
// given
AiMessageSaveRequest request = AiMessageSaveRequest.builder()
.aiGameRoomId("room-123")
.gameId("game-456")
.content("AI 응답 메시지")
.turnNumber(1)
.build();
// Mock: getNextMessageOrder 결과
given(aiGameMessageRepository.findMaxMessageOrderByTurn("room-123", 1))
.willReturn(Collections.emptyList());
// Mock: 저장된 메시지 반환
AiGameMessage savedMessage = AiGameMessage.builder()
.id("saved-id")
.aiGameRoomId("room-123")
.gameId("game-456")
.content("AI 응답 메시지")
.messageType(AiMessageType.AI)
.turnNumber(1)
.messageOrder(1)
.createdAt(Instant.now())
.build();
given(aiGameMessageRepository.save(any(AiGameMessage.class)))
.willReturn(savedMessage);
// when
AiGameMessageDto result = aiGameMessageService.saveAiMessage(request);
// then
assertThat(result).isNotNull();
assertThat(result.getContent()).isEqualTo("AI 응답 메시지");
assertThat(result.getMessageType()).isEqualTo(AiMessageType.AI);
assertThat(result.getTurnNumber()).isEqualTo(1);
// Mock 호출 검증
then(aiGameValidator).should().validateGameRoom("room-123");
then(aiGameMessageRepository).should().save(any(AiGameMessage.class));
}
@Test
@DisplayName("메시지 순서 계산 테스트")
void messageOrder_test() {
// given - 첫 번째 메시지인 경우
AiMessageSaveRequest request = AiMessageSaveRequest.builder()
.aiGameRoomId("room-123")
.content("첫 번째 메시지")
.turnNumber(1)
.build();
// Mock: 기존 메시지가 없는 경우
given(aiGameMessageRepository.findMaxMessageOrderByTurn("room-123", 1))
.willReturn(Collections.emptyList());
AiGameMessage savedMessage = AiGameMessage.builder()
.id("saved-id")
.messageOrder(1) // 첫 번째 메시지이므로 1
.turnNumber(1)
.content("첫 번째 메시지")
.messageType(AiMessageType.AI)
.createdAt(Instant.now())
.build();
given(aiGameMessageRepository.save(any(AiGameMessage.class)))
.willReturn(savedMessage);
// when
AiGameMessageDto result = aiGameMessageService.saveAiMessage(request);
// then
assertThat(result.getMessageOrder()).isEqualTo(1);
then(aiGameMessageRepository).should().findMaxMessageOrderByTurn("room-123", 1);
}
}
# 이렇게 설정했다가 계속 실패
spring.data.mongodb.username=${SPRING_DATA_MONGODB_USERNAME}
spring.data.mongodb.password=${SPRING_DATA_MONGODB_PASSWORD}
# 이렇게 주석 처리해서 해결
#spring.data.mongodb.username=${SPRING_DATA_MONGODB_USERNAME}
#spring.data.mongodb.password=${SPRING_DATA_MONGODB_PASSWORD}
교훈: 로컬 환경과 테스트 환경 설정을 다르게 가져가자!
// 🚫 이렇게 하면 에러 발생
@BeforeEach
void setUp() {
given(mockService.someMethod()).willReturn(value); // 사용되지 않음
}
// ✅ 해결: 실제 사용되는 테스트에서만 Mock 설정
@Test
void testMethod() {
given(mockService.someMethod()).willReturn(value); // 실제 사용됨
// ... 테스트 로직
}
// 🚫 테스트에서는 이렇게 호출했는데
assertThat(result.getQueuePosition()).isEqualTo(6);
// 🚫 실제로는 이런 메서드명이었음
public int getCurrentPosition() { ... }
// ✅ 해결
assertThat(result.getCurrentPosition()).isEqualTo(6);
교훈: IDE의 자동완성을 믿자! 타이핑하지 말고 자동완성 쓰자.
// 🚫 이렇게 하면 NPE 발생
@Mock
SomeService someService; // Mock 생성만 하고
@Test
void test() {
// Mock 설정 없이 바로 사용
service.doSomething(); // NPE 발생!
}
// ✅ 해결: 필요한 모든 Mock 설정
@Test
void test() {
given(someService.method()).willReturn(value);
given(anotherService.method()).willReturn(value);
// 모든 의존성 Mock 설정 후 테스트
}
// Repository: TestContainers로 실제 MongoDB 테스트
@Testcontainers
@DataMongoTest
class AiGameMessageRepositoryTest { ... }
// Service: Mockito로 비즈니스 로직만 집중
@ExtendWith(MockitoExtension.class)
class AiGameMessageServiceTest { ... }
선택 이유: 시간 필드 변경(LocalDateTime → Instant) 등 데이터 정합성이 중요
// QueueManager: Redis Mock으로 큐 로직 테스트
@Mock StringRedisTemplate redisTemplate;
@Mock ListOperations<String, String> listOperations;
// Service: WebSocket, Redis 등 외부 의존성 많음
@Mock MatchingQueueManager queueManager;
@Mock MatchingWebSocketService webSocketService;
선택 이유: 비즈니스 로직보다는 연동 로직이 중심
// Factory: 실제 구현체들로 패턴 동작 확인
@Test
void getService_ai_success() {
given(aiRoomService.getSupportedRoomType()).willReturn(RoomType.AI_GAME);
RoomServiceFactory factory = new RoomServiceFactory(roomServices);
RoomService result = factory.getService(RoomType.AI_GAME);
assertThat(result).isEqualTo(aiRoomService);
}
선택 이유: 패턴 자체의 동작이 중요
DTO → Repository → Service → Controller
| 상황 | 추천 방법 | 이유 |
|---|---|---|
| DB 연동 중요 | TestContainers | 실제 DB 동작 확인 |
| 비즈니스 로직만 | Mock | 빠른 테스트, 외부 의존성 제거 |
| 빠른 피드백 필요 | Mock | 테스트 실행 속도 |
| 실제 환경과 동일 | TestContainers | 프로덕션 환경과 유사 |
// ✅ 좋은 예: 테스트별로 필요한 Mock만 설정
@Test
void specificTest() {
// 이 테스트에 필요한 Mock만 설정
given(mockA.method()).willReturn(value);
// 테스트 실행
// 검증
}
// ✅ 공통 Mock은 @BeforeEach에서
@BeforeEach
void setUp() {
// 모든 테스트에서 공통으로 사용하는 Mock
given(commonMock.method()).willReturn(commonValue);
}
에러 메시지 꼼꼼히 읽기
Mock 설정 확인
given() 설정이 실제 호출과 일치하는지메서드명 확인
given-when-then 순서 점검
// ✅ 테스트 데이터 생성 메서드 활용
private AiGameMessage createTestMessage(String roomId, String content, int order) {
return AiGameMessage.builder()
.aiGameRoomId(roomId)
.content(content)
.messageOrder(order)
.messageType(AiMessageType.USER)
.createdAt(Instant.now())
.build();
}
// ✅ 상수로 테스트 데이터 관리
private static final String TEST_ROOM_ID = "test-room-123";
private static final String TEST_USER_ID = "test-user-456";
리팩토링 두려움 해소
// LocalDateTime → Instant 변경도 안심하고 진행
private LocalDateTime createdAt; // Before
private Instant createdAt; // After
테스트가 있으니까 변경해도 바로 확인 가능!
버그 발견 속도 향상
// 필드명 틀린 것도 테스트에서 먼저 발견
assertThat(result.getQueuePosition()).isEqualTo(6); // 컴파일 에러로 바로 발견!
코드 이해도 증가
자신감 향상
초기 설정 시간
테스트 유지보수
완벽한 테스트의 부담
> Task :test
AiServiceRequestTest > Builder로 객체 생성 테스트 PASSED
AiServiceRequestTest > JSON 직렬화 테스트 PASSED
AiServiceRequestTest > JSON 역직렬화 테스트 PASSED
AiGameMessageRepositoryTest > 메시지 저장 후 조회 테스트 PASSED
AiGameMessageRepositoryTest > 룸별 메시지 조회 테스트 PASSED
AiGameMessageRepositoryTest > 턴별 최대 메시지 순서 조회 테스트 PASSED
AiGameMessageServiceTest > AI 메시지 저장 성공 테스트 PASSED
AiGameMessageServiceTest > 메시지 순서 계산 테스트 PASSED
MatchingQueueManagerTest > 큐에 사용자 추가 성공 테스트 PASSED
MatchingQueueManagerTest > 큐에서 사용자 제거 성공 테스트 PASSED
MatchingQueueManagerTest > 큐 크기 조회 테스트 PASSED
MatchingServiceTest > 매칭 참가 성공 테스트 PASSED
MatchingServiceTest > 매칭 취소 성공 테스트 PASSED
RoomServiceFactoryTest > AI 룸 서비스 조회 성공 테스트 PASSED
RoomServiceFactoryTest > null 입력시 예외 발생 테스트 PASSED
BUILD SUCCESSFUL in 8s
18개 테스트 모두 통과!
@WebMvcTest(AiGameRoomController.class)
class AiGameRoomControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
AiGameRoomService aiGameRoomService;
@Test
void createRoom_success() throws Exception {
// MockMvc로 API 테스트
mockMvc.perform(post("/api/ai/rooms")
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.roomName").value("테스트 룸"));
}
}
@SpringBootTest
@Testcontainers
class AiGameIntegrationTest {
// 전체 애플리케이션 컨텍스트로 테스트
}
// build.gradle에 JaCoCo 플러그인 추가
plugins {
id 'jacoco'
}
jacoco {
toolVersion = "0.8.8"
}
// 테스트 먼저 작성
@Test
void rollDice_d20_returnsRandomValue() {
// given
DiceType diceType = DiceType.D20;
// when
DiceResult result = diceService.roll(diceType);
// then
assertThat(result.getValue()).isBetween(1, 20);
}
// 그 다음 구현
public class DiceService {
public DiceResult roll(DiceType diceType) {
// 구현
}
}
테스트 코드 초보자였던 제가 3개 도메인 테스트를 완주하면서 배운 가장 중요한 것:
완벽하지 않아도 시작하자!
DTO 테스트 하나부터 시작해서 점점 늘려가다 보니 어느새 Repository, Service 테스트까지 작성하게 되었습니다.
테스트 코드를 작성하면서 깨달은 것은 버그 방지보다도 개발자의 자신감이 가장 큰 수확이었습니다.
실제 프로젝트: GitHub - DungeonTalk Backend
테스트 코드 위치: src/test/java/org/com/dungeontalk/domain/
이 글이 테스트 코드 작성을 망설이고 있는 분들에게 도움이 되었으면 좋겠습니다!
궁금한 점이 있으시면 댓글로 언제든 물어보세요! 🙋♂️