[springboot] springboot test webclient

개요

신규 API를 개발하면서 있었던 일입니다.
외부 API를 호출하고 해당 응답 값을 가져오기만 하는 간단한 작업이었습니다.
외부 API와의 스펙은 이미 공유되어 있고 그것에 맞춰 개발만 하면 되는 것이었습니다.
하지만 세상사 모든게 만만한게 아니었죠.
외부 API는 평시에는 전혀 문제 없었으나 모종의 이유로 content-type이
application/json에서 text/html로 변경 되며 html로 구성된 에러 메시지가 노출됩니다.
만약 내가 webclient를 사용 하고 있을때..
이런 경우가 흔하지는 않지만 손쉽게 테스트하는 방법이 있을까?
이런 고민을 나눠보도록 하겠습니다.

참고 - https://www.baeldung.com/spring-mocking-webclient

기존엔 어떤 테스트를 사용하셨나요?

@SpringBootTest기반의 테스트가 많았습니다.
@SpringBootTest는 실제로 springboot를 가동하기 때문에
spring context를 전부 다 사용할 수 있는 장점이 있습니다. 하지만 그만큼 단점도 많죠.
간단하게 정리 하자면 아래와 같습니다.

  • 무거움 -> springboot 가동
  • 외부(DB, API) 연동시 직접 연동 -> DB CUD 이벤트일 경우 @Transactional 어노테이션을 이용하여 롤백하면 되지만 DB 부담이 큼

물론 @SpringBootTest 뿐만 아니라 mockito를 이용한 테스트도 사용합니다.
다만..webclient의 메서드 체이닝을 전부 다 mockito로 구현 하기엔 많은 번거로움이 있습니다.
여기서는 mockito와 좀 더 개선된 webclient 테스트 환경을 제공해 주는 MockWebServer에 대해 이야기 해 보도록 하겠습니다.

mocking 방법

  • mockito을 이용한 webclient mocking
  • mockWebServer를 이용하여 실제 webclient 작동

mockito

일반적으로 mockito를 많이 사용하죠.
메서드 호출에 대한 응답 값을 미리 정의할 수 있기 때문에 선호하는 라이브러리입니다.

EmployeeRepository

public class EmployeeRepository {

    public EmployeeRepository(String baseUrl) {
        this.webClient = WebClient.create(baseUrl);
    }
    public Mono<Employee> getEmployeeById(Integer employeeId) {
        return webClient
                .get()
                .uri("http://localhost:8080/employee/{id}", employeeId)
                .retrieve()
                .bodyToMono(Employee.class);
    }
}
  • webclient repository 코드를 생성합니다.

EmployeeRepositoryTest

@ExtendWith(MockitoExtension.class)
public class EmployeeRepositoryTest {
  
    private final EmployeeRepository employeeRepository;
   
    @Test
    void givenEmployeeId_whenGetEmployeeById_thenReturnEmployee() {

        Integer employeeId = 100;
        Employee mockEmployee = new Employee(100, "Adam", "Sandler", 
          32, Role.LEAD_ENGINEER);
        when(webClientMock.get())
          .thenReturn(requestHeadersUriSpecMock);
        when(requestHeadersUriMock.uri("/employee/{id}", employeeId))
          .thenReturn(requestHeadersSpecMock);
        when(requestHeadersMock.retrieve())
          .thenReturn(responseSpecMock);
        when(responseMock.bodyToMono(Employee.class))
          .thenReturn(Mono.just(mockEmployee));

        Mono<Employee> employeeMono = employeeRepository.getEmployeeById(employeeId);

        StepVerifier.create(employeeMono)
          .expectNextMatches(employee -> employee.getRole()
            .equals(Role.LEAD_ENGINEER))
          .verifyComplete();
    }

}
  • webclient 메서드 체이닝마다 when/thenReturn mocking 데이터를 적용합니다.

위 테스트 코드를 보면 알겠지만 상당히 복잡합니다. 메서드 체이닝마다 mocking 데이터를 삽입해야 하기 때문에 코드가 늘어나죠.
그 이유는 webclientfluent interface로 설계되었기 때문입니다.

참고 - fluent interface

자 다음은 MockWebServer를 사용 해 보도록 하겠습니다.

MockWebServer

MockWebServerbaeldung에서 말하길 HTTP 요청을 수신하고 응답할 수 있는 작은 웹 서버입니다.
실제로 응답을 받을 수 있기 때문에 endpoint 응답만 정의해 놓으면 손쉽게 사용할 수 있습니다.
MSA 환경이라면 더욱 더 필수죠. 단점이라면 아래 종속성 추가되는 부분이라고 생각됩니다.

종속성 추가

dependencies {
    testImplementation 'com.squareup.okhttp3:okhttp:4.11.0'
    testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0'
}

build.gradle에 okhttp, mockwebserver 종속성을 추가합니다.

mockWebServerTest

@ExtendWith(MockitoExtension.class)
class MockWebServerTests {

  public static MockWebServer mockWebServer;

  private final ObjectMapper objectMapper = new ObjectMapper();

  private WebClient webClient;

  @BeforeAll
  static void setUp() throws IOException {
    mockWebServer = new MockWebServer();
    mockWebServer.start();
  }

  @AfterAll
  static void down() throws IOException {
    mockWebServer.shutdown();
  }

  @BeforeEach
  void initialize() {
    String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort());
    this.webClient = WebClient.create(baseUrl);
  }

  @Test
  void getErrorHtmlResponse() throws Exception {
    String response = """
        <html>
            <head>
                <META http-equiv="Content-Type" content="text/html; charset=UTF-8">
            </head>
            <body>
                <div align="center">
                    <div>500 Internal Server Error</div>
                    <div><P>Unknown failure</P></div>
                </div>
            </body>
        </html>
        """;

    mockWebServer.enqueue(new MockResponse()
        .setBody(objectMapper.writeValueAsString(response))
        .addHeader("Content-Type", "text/html"));

    Mono<Response> respone = webClient
        .get()
        .uri("/error")
        .accept(MediaType.APPLICATION_JSON)
        .exchangeToMono(clientResponse -> {
          if (isNotSupportedContentType(clientResponse)) {
            return clientResponse.bodyToMono(String.class)
                .flatMap(errorBody -> Mono.error(new RuntimeException(errorBody)));
          }
          return clientResponse.bodyToMono(Response.class);
        });

    StepVerifier.create(respone)
        .expectErrorMatches(throwable -> throwable instanceof ConnectException &&
            throwable.getMessage().contains("500 Internal Server Error") &&
            throwable.getMessage().contains("Unknown failure"))
        .verify();

    RecordedRequest recordedRequest = mockWebServer.takeRequest();
    assertEquals("GET", recordedRequest.getMethod());
  }

  private boolean isNotSupportedContentType(ClientResponse clientResponse) {
    return clientResponse.headers().contentType()
        .stream()
        .anyMatch(contentType -> contentType.includes(MediaType.TEXT_HTML));
  }
}
  • BeforeAll, AfterAll 단계에서 mockwebserver 시작/종료를 담당합니다.
  • BeforeEach 단계에서 endpoint를 지정합니다.
  • getErrorHtmlResponse 메서드에서 실제 webclient를 연동하는 테스트를 진행합니다.

단락별로 나눠서 보도록 하겠습니다.

    String response = """
        <html>
            <head>
                <META http-equiv="Content-Type" content="text/html; charset=UTF-8">
            </head>
            <body>
                <div align="center">
                    <div>500 Internal Server Error</div>
                    <div><P>Unknown failure</P></div>
                </div>
            </body>
        </html>
        """;

    mockWebServer.enqueue(new MockResponse()
        .setBody(objectMapper.writeValueAsString(response))
        .addHeader("Content-Type", "text/html"));

먼저 text/html로 응답 하는 부분을 mocking할 수 있도록 생성합니다. 또한 content-type은 text/html로 정의합니다.

    Mono<Response> respone = webClient
        .get()
        .uri("/error")
        .accept(MediaType.APPLICATION_JSON)
        .exchangeToMono(clientResponse -> {
          if (isNotSupportedContentType(clientResponse)) {
            return clientResponse.bodyToMono(String.class)
                .flatMap(errorBody -> Mono.error(new RuntimeException(errorBody)));
          }
          return clientResponse.bodyToMono(Response.class);
        });

    StepVerifier.create(respone)
        .expectErrorMatches(throwable -> throwable instanceof RuntimeException &&
            throwable.getMessage().contains("500 Internal Server Error") &&
            throwable.getMessage().contains("Unknown failure"))
        .verify();
    
    ...

    private boolean isNotSupportedContentType(ClientResponse clientResponse) {
        return clientResponse.headers().contentType()
        .stream()
        .anyMatch(contentType -> contentType.includes(MediaType.TEXT_HTML));
    }

webclient exchangeToMono 체이닝 단계에서 content-type에 따른 분기 처리합니다.

  • isNotSupportedContentType 메서드를 통해 content-typetext/html이 포함 여부를 체크합니다.

StepVerifier를 통해 테스트 결과를 검증합니다.

  • RuntimeException 여부 및 throwable.getMessage()에 에러 관련 메시지가 있는지 확인 합니다.

test_result.png

정상적으로 잘 작동된 것을 볼 수 있습니다.

springboot test webclient

2023

[linux] shell script version compare

최대 1 분 소요

개요 linux를 사용하다 보면 version 비교하는 기능이 필요합니다. 특히 기존 설치된 패키지의 version을 확인하여 업데이트할 경우가 있겠죠. 아래와 같이 간단한 shell script로 구현할 수 있습니다.

[jenkins] jenkins docker install

2 분 소요

ci/cd 오픈소스 도구로 가장 많이 사랑 받는 jenkins에 대해 포스팅 해보겠습니다. 먼저 설치부터 해야겠지요? 항상 패키지 매니저로 설치했었는데 이번에는 docker로 설치해보도록 하겠습니다.

[springboot] springboot history

2 분 소요

springboot 탄생 배경 springboot란 spring framework를 좀 더 쉽게 개발/배포할려는 목적으로 만들어 졌습니다. 2012년 Mike Youngstrom은 spring 프레임워크에서 컨테이너 없는 웹 애플리케이션 아키텍처에 대한 지원을 요청하는 spring...

[springboot] springboot3 querydsl 적용

1 분 소요

개요 springboot3로 메이저 업그레이드 되면서 JPA + querydsl 셋팅 환경에 변화가 생겼습니다. 기존 의존성으로는 작동하지 않고 jakarta classification을 추가해야 작동하는 이슈가 발생합니다. springboot3부터 javax -> jakar...

[springboot] springboot3 migration

최대 1 분 소요

개요 2022년 하반기에 springboot3가 공식 release 되었습니다. springboot2가 2018년 상반기에 release되고 나서 새롭게 판올림 버전으로 가장 큰 변화로는 아래와 같습니다. spring framework 6 적용 최소 사양 JDK 17 ...

[springboot] springboot initializer

최대 1 분 소요

개요 항상 intellij ultimate 버전만 사용하고 있었는데 무슨 바람이 난건지.. intellij ce 버전에 도전하였습니다. springboot 프로젝트 생성이며.. 그 밖에 기본적으로 될꺼라 싶은것 중에 안되는 녀석들도 꽤 있더군요. 이번 시간엔 간단하게 spingbo...

[querydsl] querydsl No release for a long time

최대 1 분 소요

개요 JPA를 spring data jpa + querydsl과의 조합으로 접하는 경우가 많습니다. spring data jpa에서 제공해주는 specification으로도 충분히 해낼수 있지만 querydsl에 비할바는 아닙니다. entity에 wrapper Q클래스를 생성하여 ...

[jekyll] jekyll install

6 분 소요

개요 오랫동안 방치했던 블로그를 다시 열면서 jekyll를 다시 설치해봤습니다. 설치 jekyll 프로젝트로 이동하여 아래 명령어를 입력합니다. gem install jekyll bundler Fetching pathutil-0.16.2.gem Fetching terminal-t...

맨 위로 이동 ↑

2021

[linux] Parse yaml

최대 1 분 소요

bash를 사용하여 yaml 파일을 파싱 및 환경 변수로 손쉽게 등록할 수 있습니다.

[유틸리티] Mock Http Status Test

최대 1 분 소요

외부 통신에 대한 Error 처리는 앱을 더욱 더 견고하게 만들 수 있습니다. Error 처리를 위해 엔드포인트에 대한 Http Status Code를 억지로 생성하는것은 매우 귀찮은 일이라고 할까요? 보다 간편하게 Mock 서버를 두는게 더 효율적이라고 볼 수 있습니다.

맨 위로 이동 ↑