훈훈훈

Spring boot :: Mockito 로 WebClient 테스트 하기 본문

Spring Framework/개념

Spring boot :: Mockito 로 WebClient 테스트 하기

훈훈훈 2021. 9. 26. 23:40

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

http://wonwoo.ml/index.php/post/2364

Comments