프로젝트

Mockito 테스트에서 @Value 주입 문제 해결하기: ReflectionTestUtils 사용기

밤린 2024. 12. 3. 14:13

최근에 진행하던 프로젝트에서 테스트 코드를 작성하면서 생각보다 애를 먹었던 트러블슈팅 사례를 공유하고자 합니다. Spring에서 의존성 주입과 Mockito를 사용한 단위 테스트를 진행하면서 발생한 NullPointerException 문제를 해결하기 위해 ReflectionTestUtils를 사용한 경험을 기록해봅니다.

문제 상황

테스트 대상은 MapService라는 클래스였고, 이 클래스는 Kakao API의 키 값을 @Value 어노테이션을 통해 application.properties에서 주입받고 있었습니다.

@Service
@RequiredArgsConstructor
public class MapService {
    private final MapInterface mapInterface;
    @Value("${kakao.api-key}")
    private String apiKey;

    // ...
}

위의 MapService를 테스트하기 위해 Mockito를 사용하여 단위 테스트를 작성했습니다. mapInterface는 Mock 객체로 대체했으며, API 호출에 대한 Mock 동작을 설정하려고 했습니다.

@Test
void testGetMapInfo_Success() {
    // given
    double originLat = 37.5665;
    double originLon = 126.9780;
    double destLat = 35.1796;
    double destLon = 129.0756;
    String origin = originLon + "," + originLat;
    String dest = destLon + "," + destLat;

    // Mocking ResponseDto
    RouteResponseDto routeResponseDto = RouteResponseDto.builder()
            .routes(routes)
            .build();

    given(mapInterface.getMapInfo(origin, dest, "testApiKey")).willReturn(routeResponseDto);

    // when
    CompletableFuture<MapInfoResponseDto> result = mapService.getMapInfo(originLat, originLon, destLat, destLon);

    // then
    assertNotNull(result);
    // ...
}

위와 같이 테스트를 작성했지만, 테스트 실행 시 NullPointerException이 발생했습니다. 문제는 apiKeynull로 설정되어 있었고, 이는 MapService 내부의 getMapInfo() 메서드에서 제대로 Mock 동작이 이루어지지 않는 원인이었습니다.

문제 원인

문제의 핵심은 @Value를 통해 외부 설정에서 주입되는 apiKey 값이 단위 테스트에서는 설정되지 않아 기본적으로 null로 남아있다는 점이었습니다. 이는 Spring이 실제 실행될 때 application.properties 파일을 읽어와 주입하기 때문에, 단위 테스트 환경에서는 별도의 설정이 없으면 apiKey가 제대로 초기화되지 않는 것입니다.

Mockito에서는 mapInterface.getMapInfo()를 호출할 때, 주어진 매개변수가 설정한 것과 일치하지 않으면 null을 반환합니다. 테스트에서는 apiKey"testApiKey"로 하드코딩되어 있었지만, 실제 MapService 내부의 apiKeynull이었기 때문에 Mock 동작이 일치하지 않았던 것입니다.

해결 방법: ReflectionTestUtils 사용

이 문제를 해결하기 위해 Spring에서 제공하는 유틸리티 클래스인 ReflectionTestUtils를 사용했습니다. ReflectionTestUtils.setField() 메서드를 이용하면 특정 객체의 필드를 리플렉션을 통해 직접 설정할 수 있습니다. 이를 통해 apiKey 값을 테스트 코드에서 원하는 대로 설정할 수 있었습니다.

@BeforeEach
void setUp() {
    MockitoAnnotations.openMocks(this);
    ReflectionTestUtils.setField(mapService, "apiKey", "testApiKey");
}

위와 같이 @BeforeEach 메서드에서 ReflectionTestUtils.setField()를 사용해 mapService 객체의 apiKey 필드를 직접 설정해주었습니다. 이렇게 함으로써 테스트에서 호출되는 apiKey와 Mock 설정에서 사용한 "testApiKey" 값이 일치하게 되었고, 그 결과 Mock 동작이 정상적으로 수행될 수 있었습니다.

결과

ReflectionTestUtils를 사용하여 apiKey 값을 수동으로 설정해줌으로써, NullPointerException 문제가 해결되었습니다. 이제 Mock 설정이 정확히 일치하게 되었고, 테스트가 정상적으로 성공적으로 통과할 수 있었습니다.

@Test
void testGetMapInfo_Success() {
    // given
    double originLat = 37.5665;
    double originLon = 126.9780;
    double destLat = 35.1796;
    double destLon = 129.0756;
    String origin = originLon + "," + originLat;
    String dest = destLon + "," + destLat;
    
    SummaryDto summaryDto = SummaryDto.builder()
            .distance(1000)
            .duration(600)
            .build();
    RouteDto routeDto = RouteDto.builder()
            .summary(summaryDto)
            .build();
    LinkedList<RouteDto> routes = new LinkedList<>();
    routes.add(routeDto);
    
    RouteResponseDto routeResponseDto = RouteResponseDto.builder()
            .routes(routes)
            .build();

    given(mapInterface.getMapInfo(origin, dest, "testApiKey")).willReturn(routeResponseDto);

    // when
    CompletableFuture<MapInfoResponseDto> result = mapService.getMapInfo(originLat, originLon, destLat, destLon);

    // then
    assertNotNull(result);
    assertFalse(result.isCompletedExceptionally());
    assertDoesNotThrow(() -> {
      MapInfoResponseDto mapInfo = result.join();
      assertNotNull(mapInfo);
      assertEquals(1000, mapInfo.getDistance());
      assertEquals(600, mapInfo.getDuration());
    });
    verify(mapInterface, times(1)).getMapInfo(origin, dest, "testApiKey");
}

마무리

이번 문제를 통해 단위 테스트에서의 필드 값 주입Mock 설정 일치의 중요성을 다시 한 번 깨달을 수 있었습니다. 특히 Spring에서 외부 설정 파일을 사용하는 경우, 단위 테스트 환경에서 그 설정이 제대로 주입되지 않으면 예상치 못한 오류가 발생할 수 있음을 알게 되었습니다.