프로젝트에서 외부 쇼핑몰 API(Naver 쇼핑 오픈API)를 연동한 NaverApiClient
클래스를 구현했고 이 클래스에 대한 단위 테스트를 작성했다. @SpringBootTest
를 붙여 통합 테스트로 구성하는 대신 Mockito를 활용한 순수 단위 테스트로 작성해 성능과 유지보수 측면에서 최적화된 테스트 구조를 구성하고자 했다.
@Mock
private RedisQuotaManager quotaManager;
@Mock
private RestTemplate restTemplate;
@Mock
은 테스트 대상 객체가 의존하는 외부 클래스의 가짜(mock) 객체를 만들어 주는 어노테이션이다. RedisQuotaManager
나 RestTemplate
처럼 외부 요청이나 내부 상태가 필요하거나 불안정한 의존성은 진짜 인스턴스를 쓰면 테스트가 불안정해진다. 따라서 동작만 시뮬레이션하는 가짜 객체를 만들어 테스트 흐름을 제어할 수 있다.
@InjectMocks
private NaverApiClient naverApiClient;
@InjectMocks
는 테스트 대상 객체를 생성하면서 위에서 만든 @Mock
객체들을 해당 클래스의 생성자나 필드에 자동으로 주입해준다. NaverApiClient
는 실제로 RedisQuotaManager
, RestTemplate
에 의존하므로 이 어노테이션을 통해 테스트 대상 객체가 자동으로 완성된다. 즉, 테스트 대상 객체를 수동으로 생성하고 의존성을 주입하지 않아도 되도록 도와주는 어노테이션이다.
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
TestReflection.setField(naverApiClient, "clientId", "test-client-id");
TestReflection.setField(naverApiClient, "clientSecret", "test-client-secret");
}
이 코드는 @Mock
, @InjectMocks
등의 어노테이션을 인식하고 실제 mock 객체들을 초기화한다. JUnit 5 기준에서는 @ExtendWith(MockitoExtension.class)
로 대체할 수도 있지만 지금 구조처럼 수동으로 초기화할 때는 반드시 openMocks(this)
를 호출해줘야 한다.
@BeforeEach
로 설정하여 매 테스트마다 깨끗한 상태로 mock 환경을 리셋하는 것도 안정성을 위한 좋은 습관이다.
TestReflection.setField(naverApiClient, "clientId", "test-client-id");
TestReflection.setField(naverApiClient, "clientSecret", "test-client-secret");
NaverApiClient
의 clientId
, clientSecret
은 @Value
로 외부 설정 파일에서 주입받는 필드다. 하지만 Mockito 기반 테스트에서는 Spring Context를 띄우지 않기 때문에 @Value
가 동작하지 않는다. 이를 해결하기 위해 Java Reflection을 사용해 private 필드에 직접 값을 주입했다.
TestReflection
유틸 클래스 설명
static class TestReflection {
public static void setField(Object target, String fieldName, Object value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.set(target, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
이 유틸 클래스는 Reflection을 사용해 private 필드에 직접 접근하고 값을 변경하는 기능을 제공한다. Spring 없이 테스트할 때 @Value
가 동작하지 않으므로 clientId
, clientSecret
을 강제로 주입하기 위해 사용되었다. 이는 Mockito 기반 테스트에서 꽤 흔히 쓰이는 방식으로 Spring Context를 띄우지 않고도 필요한 설정값만 주입할 수 있어 테스트 속도와 독립성을 높이는 데 효과적이다.
처음엔 NaverApiClient
내부에서 아래처럼 RestTemplate
을 직접 생성하고 있었다.
private final RestTemplate restTemplate = new RestTemplate();
이렇게 되면 Mockito가 생성한 @Mock
RestTemplate 객체가 주입되지 않고 exchange()
호출 시 실제 외부 API 호출이 발생할 수도 있다. 즉, 아래와 같은 stub은 전혀 동작하지 않게 된다.
when(restTemplate.exchange(...)).thenReturn(mockResponse);
아래처럼 RestTemplate을 외부에서 주입받도록 변경했다.
private final RestTemplate restTemplate;
그리고 실제 프로젝트에서 다음과 같이 Bean으로 등록했다.
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
이제 테스트에서는 @Mock
RestTemplate이 정상적으로 동작하며 exchange()
호출을 자유롭게 시뮬레이션할 수 있게 되었다.
when(quotaManager.canCall()).thenReturn(true);
quotaManager.canCall()
호출 시 true를 리턴하도록 미리 정의하는 것이다.
when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(JsonNode.class)))
.thenReturn(responseEntity);
RestTemplate.exchange(...)
메서드 호출 시 우리가 만든 가짜 responseEntity
를 리턴하게 설정한다.
any(...)
- 인자의 값은 무시하고 해당 타입이면 매칭
eq(value)
- 해당 정확한 값일 경우에만 매칭
여기선 URI와 HttpEntity는 아무거나 와도 되고 Method는 반드시 GET
, 결과 타입은 JsonNode.class
로 설정된 것과 일치해야 한다.
assertThat(result).hasSize(1);
assertThat(result.get(0).title()).isEqualTo("테스트 상품");
응답으로 나온 결과 리스트에 아이템이 정확히 하나 있고 JSON 파싱 결과가 잘 동작했는지를 검증한다.
when(quotaManager.canCall()).thenReturn(false);
Redis quota가 초과된 상황을 가정한다.
assertThatThrownBy(() -> naverApiClient.search("테스트", 1, 10))
.isInstanceOf(ErrorException.class)
.hasMessage(ExceptionEnum.QUOTA_EXCEEDED.getMessage());
이 상황에서는 search()
메서드가 예외를 발생시켜야 하므로 해당 예외가 정확히 발생하는지 확인한다.
@ActiveProfiles("test")
class NaverApiClientTest {
static class TestReflection {
public static void setField(Object target, String fieldName, Object value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.set(target, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
@Mock
private RedisQuotaManager quotaManager;
@Mock
private RestTemplate restTemplate;
@InjectMocks
private NaverApiClient naverApiClient;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
TestReflection.setField(naverApiClient, "clientId", "test-client-id");
TestReflection.setField(naverApiClient, "clientSecret", "test-client-secret");
}
@DisplayName("네이버 API에서 정상적으로 상품을 파싱할 수 있다.")
@Test
void testSearch_success() throws Exception {
// given
when(quotaManager.canCall()).thenReturn(true);
String json = """
{
"items": [
{
"title": "테스트 상품",
"link": "http://example.com",
"image": "http://example.com/image.jpg",
"lprice": "12345",
"mallName": "테스트몰"
}
]
}
""";
JsonNode mockResponse = new ObjectMapper().readTree(json);
ResponseEntity<JsonNode> responseEntity = new ResponseEntity<>(mockResponse, HttpStatus.OK);
when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(JsonNode.class)))
.thenReturn(responseEntity);
// when
List<ProductResponseDto> result = naverApiClient.search("테스트", 1, 10);
// then
assertThat(result).hasSize(1);
assertThat(result.get(0).title()).isEqualTo("테스트 상품");
assertThat(result.get(0).lprice()).isEqualTo(12345);
}
@DisplayName("쿼터 초과 시 예외가 발생해야 한다.")
@Test
void testSearch_quotaExceeded() {
// given
when(quotaManager.canCall()).thenReturn(false);
// when then
assertThatThrownBy(() -> naverApiClient.search("테스트", 1, 10))
.isInstanceOf(ErrorException.class)
.hasMessage(ExceptionEnum.QUOTA_EXCEEDED.getMessage());
}
}
이번 테스트를 통해 가장 크게 느낀 점은 테스트의 본질은 빠르고 정확한 피드백을 얻는 것이라는 점이다. @SpringBootTest
를 통해 전체 애플리케이션 컨텍스트를 띄우는 방식은 통합 테스트에는 적합하지만 단위 테스트 수준에서는 과도한 비용과 복잡성을 유발할 수 있다. 특히 단순해 보이는 RestTemplate
조차 직접 생성했는지 빈으로 주입받았는지에 따라 Mockito의 Mock 객체가 주입되지 않을 수 있다는 점은 중요한 포인트였다. 이번 경험을 통해 Spring의 DI 방식과 Mockito의 Mock 주입 방식이 서로 다르다는 점을 이해하게 되었고 테스트가 실패했을 때 단순히 stub이 잘못된 것이 아니라 Mock 자체가 주입되지 않았을 가능성도 반드시 고려해야 함을 배웠다.
따라서 앞으로는 다음과 같은 원칙을 테스트 설계의 기본 전략으로 삼고자 한다.
@Value
)이 필요한 경우에는 Reflection이나 테스트 전용 설정 객체를 통해 명시적으로 주입할 것이러한 구조는 테스트 속도를 빠르게 유지할 수 있을 뿐 아니라 불필요한 의존성과 환경 설정 문제로부터 테스트를 분리해줌으로써 더 견고하고 유지보수하기 쉬운 테스트 환경을 만드는 데 큰 도움이 된다. 이번 경험은 테스트가 어떻게 설계되어야 하는지를 고민할 수 있었던 의미 있는 과정이었다.