# MongoDB TestContainers와 Mockito로 배우는 실전 테스트 코드

MJ·2025년 8월 17일

🤔 왜 테스트 코드를 시작했을까?

TRPG 게임 백엔드를 개발하면서 매번 기능을 추가할 때마다 이런 생각이 들었습니다.

"이 코드 바꿔도 다른 기능 안 망가질까?"
"MongoDB 연동이 제대로 되는지 어떻게 확인하지?"
"서비스 로직이 복잡해지는데 테스트는 어떻게 하지?"

그래서 테스트 코드를 시작하기로 했습니다. 하지만 막상 시작하려니...

어디서부터 시작해야 할지 모르겠더라고요.

테스트 전략: DTO → Repository → Service

초보자인 저는 간단한 것부터 시작하기로 했습니다.

1단계: DTO 테스트 (워밍업)

@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부터했을까?

  • 로직이 단순해서 실패할 확률이 낮음
  • JSON 직렬화/역직렬화 확인 가능
  • 테스트 작성 패턴에 익숙해질 수 있음
  • given-when-then 패턴 연습하기 좋음
  • 제일 중요한 것은 ai 테스트 코드에 대해 설명해줘 했는데 dto를 제일 먼저 알려주었다..

2단계: Repository 테스트 (TestContainers와의 첫 만남)

진짜 도전은 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의 장점:

  • ✅ 실제 MongoDB 환경에서 테스트
  • ✅ 로컬 DB 설정 불필요
  • ✅ CI/CD에서도 동일한 환경 보장
  • ✅ 실제 DB 쿼리 동작 확인 가능

삽질했던 부분:

  • ❌ MongoDB 인증 설정 때문에 한참 헤맸음
  • @DynamicPropertySource 없이 했다가 connection 실패
  • ❌ 컨테이너 시작 시간 때문에 테스트가 느려짐
  • ❌ TestContainers 버전 호환성 문제

3단계: Service 테스트 (Mockito의 위력)

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);
    }
}

🚧 삽질 경험담 (진짜 겪은 일들)

1. MongoDB 인증 문제

# 이렇게 설정했다가 계속 실패
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}

교훈: 로컬 환경과 테스트 환경 설정을 다르게 가져가자!

2. Mockito UnnecessaryStubbingException

// 🚫 이렇게 하면 에러 발생
@BeforeEach
void setUp() {
    given(mockService.someMethod()).willReturn(value); // 사용되지 않음
}

// ✅ 해결: 실제 사용되는 테스트에서만 Mock 설정
@Test
void testMethod() {
    given(mockService.someMethod()).willReturn(value); // 실제 사용됨
    // ... 테스트 로직
}

3. 필드명 틀린 실수

// 🚫 테스트에서는 이렇게 호출했는데
assertThat(result.getQueuePosition()).isEqualTo(6); 

// 🚫 실제로는 이런 메서드명이었음
public int getCurrentPosition() { ... }

// ✅ 해결
assertThat(result.getCurrentPosition()).isEqualTo(6);

교훈: IDE의 자동완성을 믿자! 타이핑하지 말고 자동완성 쓰자.

4. NullPointerException 지옥

// 🚫 이렇게 하면 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 설정 후 테스트
}

🎯 도메인별 테스트 전략

AiChat 도메인: 실제 DB 중심

// Repository: TestContainers로 실제 MongoDB 테스트
@Testcontainers
@DataMongoTest
class AiGameMessageRepositoryTest { ... }

// Service: Mockito로 비즈니스 로직만 집중
@ExtendWith(MockitoExtension.class)
class AiGameMessageServiceTest { ... }

선택 이유: 시간 필드 변경(LocalDateTime → Instant) 등 데이터 정합성이 중요

Matching 도메인: Mock 중심

// QueueManager: Redis Mock으로 큐 로직 테스트
@Mock StringRedisTemplate redisTemplate;
@Mock ListOperations<String, String> listOperations;

// Service: WebSocket, Redis 등 외부 의존성 많음
@Mock MatchingQueueManager queueManager;
@Mock MatchingWebSocketService webSocketService;

선택 이유: 비즈니스 로직보다는 연동 로직이 중심

Room 도메인: Factory 패턴 테스트

// 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);
}

선택 이유: 패턴 자체의 동작이 중요

💡 초보자를 위한 실전 팁

1. 테스트 작성 순서

DTO → Repository → Service → Controller
  • DTO: 가장 간단, 실패할 확률 낮음
  • Repository: 실제 DB 연동 확인
  • Service: 비즈니스 로직 검증
  • Controller: API 전체 흐름 테스트

2. TestContainers vs Mock 선택 기준

상황추천 방법이유
DB 연동 중요TestContainers실제 DB 동작 확인
비즈니스 로직만Mock빠른 테스트, 외부 의존성 제거
빠른 피드백 필요Mock테스트 실행 속도
실제 환경과 동일TestContainers프로덕션 환경과 유사

3. Mock 설정 꿀팁

// ✅ 좋은 예: 테스트별로 필요한 Mock만 설정
@Test
void specificTest() {
    // 이 테스트에 필요한 Mock만 설정
    given(mockA.method()).willReturn(value);
    
    // 테스트 실행
    // 검증
}

// ✅ 공통 Mock은 @BeforeEach에서
@BeforeEach
void setUp() {
    // 모든 테스트에서 공통으로 사용하는 Mock
    given(commonMock.method()).willReturn(commonValue);
}

4. 테스트 실패시 디버깅 체크리스트

  1. 에러 메시지 꼼꼼히 읽기

    • NullPointerException → Mock 설정 확인
    • AssertionError → 예상값과 실제값 비교
  2. Mock 설정 확인

    • given() 설정이 실제 호출과 일치하는지
    • 모든 필요한 의존성이 Mock되었는지
  3. 메서드명 확인

    • 테스트의 메서드명과 실제 메서드명 일치 확인
    • IDE 자동완성 활용하기
  4. given-when-then 순서 점검

    • given: 조건 설정이 완료되었는지
    • when: 실제 동작이 올바른지
    • then: 검증 로직이 정확한지

5. 테스트 데이터 관리

// ✅ 테스트 데이터 생성 메서드 활용
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";

테스트 코드 작성 후 느낀 점

좋은 점

  1. 리팩토링 두려움 해소

    // LocalDateTime → Instant 변경도 안심하고 진행
    private LocalDateTime createdAt; // Before
    private Instant createdAt;       // After

    테스트가 있으니까 변경해도 바로 확인 가능!

  2. 버그 발견 속도 향상

    // 필드명 틀린 것도 테스트에서 먼저 발견
    assertThat(result.getQueuePosition()).isEqualTo(6); // 컴파일 에러로 바로 발견!
  3. 코드 이해도 증가

    • 테스트 작성하면서 기존 코드 흐름 파악
    • "이 메서드가 뭘 하는 거지?"에서 "이 메서드는 이렇게 동작하는구나!"
  4. 자신감 향상

    • 기능 추가할 때 "혹시 뭔가 깨지지 않을까?" → "테스트 돌려보면 되지!"

아쉬운 점

  1. 초기 설정 시간

    • TestContainers, Mock 설정 러닝커브
    • 처음엔 테스트 작성이 기능 구현보다 오래 걸림
  2. 테스트 유지보수

    • 기능 변경시 테스트도 함께 수정 필요
    • 하지만 이것도 코드 품질 향상에 도움
  3. 완벽한 테스트의 부담

    • "모든 케이스를 다 테스트해야 하나?" 하는 강박
    • → 중요한 것부터 차근차근하면 됨!

실제 테스트 실행 결과

> 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개 테스트 모두 통과!

다음 목표

1. Controller 테스트 도전

@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("테스트 룸"));
    }
}

2. 통합 테스트 시도

@SpringBootTest
@Testcontainers
class AiGameIntegrationTest {
    // 전체 애플리케이션 컨텍스트로 테스트
}

3. 테스트 커버리지 측정

// build.gradle에 JaCoCo 플러그인 추가
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.8"
}

4. 주사위 기능 TDD로 개발

// 테스트 먼저 작성
@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 테스트까지 작성하게 되었습니다.

테스트 코드를 망설이고 있는 분들께

  • "테스트 코드 어려울 것 같아" → DTO 테스트부터 시작해보세요
  • "설정이 복잡할 것 같아" → @Test 하나부터 만들어보세요
  • "시간이 오래 걸릴 것 같아" → 하루에 테스트 하나씩만 추가해보세요
  • "뭘 테스트해야 할지 모르겠어" → 가장 자주 사용하는 메서드부터

테스트 코드의 진짜 가치

테스트 코드를 작성하면서 깨달은 것은 버그 방지보다도 개발자의 자신감이 가장 큰 수확이었습니다.

  • "이 코드 바꿔도 될까?" → "테스트 돌려보면 알 수 있지!"
  • "리팩토링하고 싶은데 무서워" → "테스트가 있으니까 안심하고 해보자!"
  • "이 기능이 제대로 동작할까?" → "테스트로 확인했으니까 괜찮아!"

실제 프로젝트: GitHub - DungeonTalk Backend
테스트 코드 위치: src/test/java/org/com/dungeontalk/domain/


참고 자료


이 글이 테스트 코드 작성을 망설이고 있는 분들에게 도움이 되었으면 좋겠습니다!
궁금한 점이 있으시면 댓글로 언제든 물어보세요! 🙋‍♂️

profile
..

0개의 댓글