훈훈훈
Spring boot :: Mockito 로 WebClient 테스트 하기 본문
Introduction
WebClient 를 사용하여 외부 API 를 Mock 기반 테스트를 할 때, response 값에 대한 stub 객체를 만드는 방법에 대하여 정리해보려고 한다. 전체 코드는 Github 에서 확인할 수 있다.
Problems
일반적으로 Mockito 를 사용할 때 아래와 같은 방법으로 stub 객체를 만들 수 있다.
( Mockito 는 BDD 방식을 지원하기 때문에 when( ) 대신 좀 더 가독성이 좋은 given ( ) 을 사용하였다. )
given(webClientWrapper.get()
.uri("http://localhost:8080/test")
.retrieve()
.bodyToMono(String.class)
.block()
).willReturn("ok");
하지만 테스트를 돌릴 때 위 코드 라인이 실행이 된다면, 아래와 같이 NPE 가 발생하는 것을 볼 수 있다.
given( ) 에서 NPE 가 발생하는 이유는 WebClient 는 Fluent API 이기 때문이다.
따라서 일반적인 객체에 대한 Stub 객체를 만드는 방법으로 테스트 코드를 작성한다면 정상적으로 동작하지 않게 된다.
정리하자면, 위에서 살펴본 NPE 는 Fluent API 에서 발생하는 문제인 것을 확인했으므로, 이제 해당 API 가 무엇인지 그리고 어떻게 Stubbing 을 하는지 알아보자.
Fluent API
Baeldung 글을 보면 Fluent API는 Method chaining 을 사용하여 Builder, Factory 등을 사용하는 것으로 정리되어 있다.
누구나 알 수 있는 예시로는 Java 의 Stream API 가 있다.
Fluent API 는 Method Chaining 을 사용하기 때문에 가독성은 좋지만, 그 속에는 여러 객체와 메서드가 숨겨져서 사용되고 있다.
따라서 만약 @Mock 을 사용한다면 아래와 같이 작성할 수 있다. 해당 예제는 해외 블로그 글에서 가져왔다.
@ExtendWith(MockitoExtension.class)
class ExampleTest {
@Mock
private WebClient webClient;
@InjectMocks
private InspirationalQuotesClient cut; // class under test
@Test
void test() {
WebClient.RequestHeadersUriSpec requestHeadersUriSpec = Mockito.mock(WebClient.RequestHeadersUriSpec.class);
WebClient.ResponseSpec responseSpec = Mockito.mock(WebClient.ResponseSpec.class);
when(webClient.get()).thenReturn(requestHeadersUriSpec);
when(requestHeadersUriSpec.uri("/api/quotes")).thenReturn(requestHeadersUriSpec);
when(requestHeadersUriSpec.retrieve()).thenReturn(responseSpec);
when(responseSpec.bodyToMono(String.class)).thenReturn(Mono.just("We've escaped hell"));
String result = cut.fetchRandomQuote();
assertEquals("We've escaped hell", result);
}
}
코드를 보면 단순한 예제임에도 Stub 객체를 생성하기 위해 많은 코드를 작성해야되는 것을 볼 수 있다.
이 방법으로 테스트 코드를 작성한다면 너무 번거롭기 때문에 대안들을 찾아보다가 MockWebServer 라이브러리를 사용하는 예제를 찾긴 했다. 하지만 Mockito 만 사용해도 가능할 것 같아서 해당 방법은 패스하였다.
다시 돌아와서, Baeldung 글을 보면 Mockito 는 Answer 라는 객체로 Deep Stubbing 기능을 지원한다고 소개되어 있다.
Answer 은 enum 객체이며, 코드를 살펴 보면 여러 객체가 정의 된 것을 볼 수 있다.
그 중에서 RETURNS_DEEP_STUBS 객체를 @Mock(answer = Answers.RETURNS_DEEP_STUBS) 와 같이 사용하면 Deep Stub 을 만들 수 있다.
이제 위에서 봤던 내용들을 전체 코드로 살펴보자.
Code Example
간단하게 "ok" 문자열을 response 하는 API 를 만들고 WebClient 로 호출하는 service 코드를 테스트 하는 과정을 살펴보자.
- Controller
단순하게 "ok" 문자열을 response 하는 컨트롤러를 만들었다.
@RestController
public class TestController {
@GetMapping("/test")
public ResponseEntity<String> test() {
return ResponseEntity.ok().body("ok");
}
}
- WebClient Wrapper
WebClient 를 사용할 때, 매번 Builder 를 정의해야 되기 때문에 중복 코드가 발생헐 수 밖에 없다.
따라서 BuilderWrapper 클래스를 만들고 생성자에 builder 를 build 할 수 있게 만들었다.
실제로는 여러 값들을 공통적으로 정의해서 사용할 수 있지만, 해당 예제는 간단하게 build( ) 하였다.
@Component
public class WebClientWrapper implements WebClient {
private final WebClient webClient;
public WebClientWrapper(WebClient.Builder builder) {
webClient = builder.build();
}
@Override
// ... 생략
}
- Service
위에서 만든 WebClientWrapper 를 사용하여 "/test" path 로 요청을 보내는 간단한 코드를 만들었다.
@Service
public class WebclientWrapperService {
private final WebClientWrapper webClientWrapper;
public WebclientWrapperService(WebClientWrapper client) {
this.webClientWrapper = client;
}
public String request() {
return webClientWrapper.get()
.uri("http://localhost:8080/test")
.retrieve()
.bodyToMono(String.class)
.block();
}
}
- Test code
Deep Stub 를 사용하여 WebClient 를 테스트하는 코드를 만들었다.
해당 코드를 실행해보면 정상적으로 동작하는 것을 확인할 수 있다.
@ExtendWith(MockitoExtension.class)
class WebclientWrapperServiceTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS) // Deep Stub
WebClientWrapper webClientWrapper;
@InjectMocks
WebclientWrapperService service;
@Test
@DisplayName("WebClient_wrapper_객체_테스트")
void requestTest() {
// given
given(webClientWrapper.get()
.uri("http://localhost:8080/test")
.retrieve()
.bodyToMono(String.class)
.block()
).willReturn("ok");
// when
String result = service.request();
// then
assertThat(result).isEqualTo("ok");
}
}
추가로 만약 WebClient.Builder 를 Stubbing 해야 한다면 어떻게 해야할까??
아래처럼 WebClient.Buidler 를 직접 생성자로 넘겨준다면 코드가 약간 달라질 수 있다.
@Service
public class WebClientNotWrapperService {
private final WebClient webClient;
public WebClientNotWrapperService(WebClient.Builder builder) {
webClient = builder.build();
}
public String request() {
return webClient.get()
.uri("http://localhost:8080/test")
.retrieve()
.bodyToMono(String.class)
.block();
}
}
Builder 는 자기 자신을 리턴하기 때문에 테스트 코드에서 아래 처럼 사용한다면 NPE 가 발생하게 된다.
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
WebClient webClient;
@Mock
WebClient.Builder builder;
해당 문제를 해결하기 위해서 다시 Answer 객체에 주석을 살펴보면, RETURNS_SELF 를 사용해서 Builders 를 mocking 할 수 있는 것을 확인할 수 있다.
따라서 아래와 같이 코드를 작성을 하면 정상적으로 테스트가 되는 것을 확인할 수 있다.
@ExtendWith(MockitoExtension.class)
class WebClientNotWrapperServiceTest {
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
WebClient webClient;
@Mock(answer = Answers.RETURNS_SELF)
WebClient.Builder builder;
@InjectMocks
WebClientNotWrapperService service;
@BeforeEach
void setUp() {
given(builder.build()).willReturn(webClient);
service = new WebClientNotWrapperService(builder);
}
// 중략 ...
}
좀 더 간단한 방법은 없을까??
마지막으로 가장 심플한 방법인 WebClient 를 사용하는 로직을 객체로 감싸고 해당 객체를 테스트 하는 방법을 소개하려고 한다.
간단하게 바로 코드로 살펴보자.
위에서 살펴봤던 WebClient 로 "/test" path 에 요청을 날리는 로직을 함수로 분리하였다.
@Component
@RequiredArgsConstructor
public class WebClientRequest {
private final WebClientWrapper webClientWrapper;
public String requestTest() {
return webClientWrapper.get()
.uri("http://localhost:8080/test")
.retrieve()
.bodyToMono(String.class)
.block();
}
}
service 에서는 위에서 분리한 객체를 단순히 호출만 하도록 변경하였다.
@Service
public class WebClientObjectService {
private final WebClientRequest webClientRequest;
public WebClientObjectService(WebClientRequest webClientRequest) {
this.webClientRequest = webClientRequest;
}
public String request() {
return webClientRequest.requestTest();
}
}
테스트 코드는 간단하게 위에서 분리한 객체만 stub 객체를 만들어서 테스트를 진행하였고, 정상적으로 동작하는 것을 확인할 수 있다.
@ExtendWith(MockitoExtension.class)
class WebClientObjectServiceTest {
@Mock
WebClientRequest webClientRequest;
@InjectMocks
WebClientObjectService service;
@Test
void request() {
// given
given(webClientRequest.requestTest())
.willReturn("ok");
// when
String result = service.request();
// then
assertThat(result).isEqualTo("ok");
}
}
참고
https://www.baeldung.com/spring-mocking-webclient
https://www.baeldung.com/mockito-fluent-apis
https://rieckpil.de/creating-deep-stubs-with-mockito-to-chain-method-stubbing/
https://circlee7.medium.com/mockito-mock-answer-3212b135262a
'Spring Framework > 개념' 카테고리의 다른 글
Spring boot :: Caffeine cache 정리 (2) | 2021.10.31 |
---|---|
Spring boot :: Multiple DataSource 환경에서 @DataJpaTest 이슈 정리 및 스프링 코드 분석 (4) | 2021.10.11 |
Spring boot :: JPA, Mybatis Transaction Manager 정리 (1) | 2021.09.06 |
Spring boot :: Datasource Replication 구현 (1) | 2021.09.05 |
Spring boot :: Task Execution and Scheduling (0) | 2021.06.12 |