WebTestClient
spring-webflux에서 통신객체로 사용되는 webclient와 유사하며, webClient에서 제공되는 메서드를 동일하게 사용할 수 있고 WebTestClient는 별도의 생성 메서드와 body, cookie, header, status 검증 메서드를 제공한다.
WebTestClient 생성
webTestController를 생성할 수 있는 방법은 총 5가지가 있다.
bindToController : controller들을 제공하여 WebTestClient를 생성한다.
bindToRouterFunction : RouterFunction들을 제공하여 WebTestClient를 생성한다.
bindToApplicationContext : ApplicationContext 내의 bean들을 기반으로 WebTestClient를 생성한다.
bindToWebHandler : WebHandler를 직접 제공하여 WebTestClient를 생성한다.
bindToServer : reactor netty client를 사용해서 떠있는 서버에 직접 연결한다.
static ControllerSpec bindToController(Object... controllers) { return new DefaultControllerSpec(controllers); } static RouterFunctionSpec bindToRouterFunction(RouterFunction<?> routerFunction) { return new DefaultRouterFunctionSpec(routerFunction); } static MockServerSpec<?> bindToApplicationContext(ApplicationContext applicationContext) { return new ApplicationContextSpec(applicationContext); } static MockServerSpec<?> bindToWebHandler(WebHandler webHandler) { return new DefaultMockServerSpec(webHandler); } static Builder bindToServer() { return new DefaultWebTestClientBuilder(); } static Builder bindToServer(ClientHttpConnector connector) { return new DefaultWebTestClientBuilder(connector); }
MockServerSpec ?
bindToApplicationContext와 bindToWebHandler를 호출했을때 반환되는 MockServerSpec은 webFilter, webSessionManager, MockServerConfigurer등을 지원하며 configureClient를 통해 WebTestClient의 Builder를 반환하거나 build를 호출하여 바로 WebTestClient를 생성한다.
public interface MockServerSpec<B extends MockServerSpec<B>> {
<T extends B> T webFilter(WebFilter... filter);
<T extends B> T webSessionManager(WebSessionManager sessionManager);
<T extends B> T apply(MockServerConfigurer configurer);
Builder configureClient();
WebTestClient build();
}
ControllerSpec ?
bindToController를 사용해서 WebTestClient를 생성했을 때 반환되는 객체다.
controllerAdvice, contentRtpeResolver, corsMapping, pathMatching, argumentResolver, httpMessageCodec, formatter, validator, viewResolver등을 설정할 수 있고 MockServerSpec을 상속하여 build를 호출하여 WebTestClient를 생성하거나 configureClient로 다시 Builder로 나갈 수 있다.
public interface ControllerSpec extends MockServerSpec<ControllerSpec> {
ControllerSpec controllerAdvice(Object... controllerAdvice);
ControllerSpec contentTypeResolver(Consumer<RequestedContentTypeResolverBuilder> consumer);
ControllerSpec corsMappings(Consumer<CorsRegistry> consumer);
ControllerSpec pathMatching(Consumer<PathMatchConfigurer> consumer);
ControllerSpec argumentResolvers(Consumer<ArgumentResolverConfigurer> configurer);
ControllerSpec httpMessageCodecs(Consumer<ServerCodecConfigurer> configurer);
ControllerSpec formatters(Consumer<FormatterRegistry> consumer);
ControllerSpec validator(Validator validator);
ControllerSpec viewResolvers(Consumer<ViewResolverRegistry> consumer);
}
bindToController 사용 예제
SpringExtension, ContextConfiguration으로 GreetingController와 Advice를 bean으로 등록하고,
WebTestClient의 bindToController와 ControllerSpec을 활용하여 WebTestClient를 생성한다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
classes = {
GreetingController.class,
GreetingControllerAdvice.class
}
)
public class CreateWebTestClientByControllerExampleTest {
@MockBean
GreetingService mockGreetingService;
@Autowired
GreetingController greetingController;
@Autowired
GreetingControllerAdvice greetingControllerAdvice;
WebTestClient webTestClient;
@BeforeEach
void setup() {
webTestClient = WebTestClient.bindToController(
greetingController
).corsMappings(cors ->
cors.addMapping("/api/**"))
.controllerAdvice(greetingControllerAdvice)
.build();
}
}
bindToApplicationContext 사용 예제
bindToApplicationContext는 @EnableWebFlux를 사용해줘야한다.
@EnableWebFlux를 사용하게 되면, DispatcherHanlder가 bean으로 등록되게 되고, DispatcherHandler가 등록이 되야 관련된 webFilter, exceptionHandler 등을 사용할 수 있기 때문이다.
@EnableWebFlux
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
classes = {
GreetingController.class,
GreetingControllerAdvice.class
}
)
public class CreateWebTestClientByApplicationContextExampleTest {
@MockBean
GreetingService mockGreetingService;
@Autowired
ApplicationContext applicationContext;
WebTestClient webTestClient;
@BeforeEach
void setup() {
webTestClient = WebTestClient.bindToApplicationContext(
applicationContext
).build();
}
}
bindToServer 사용 예제
기존에 떠있는 서버에 연결하여 WebTestClient를 생성하는 방식이다.
bindToServer의 반환값은 builder이기 떄문에, builder를 활용하여 baseUrl을 설정해서 build한다.
@ExtendWith(SpringExtension.class)
public class CreateWebTestClientByServerExampleTest {
WebTestClient webTestClient;
@BeforeEach
void setup() {
WebTestClient.bindToServer()
.baseUrl("http://localhost:8080")
.build();
}
}
WebTestClient 검증
WebTestClient는 WebClient의 exchangeToMono, exchangeToFlux, retrieve를 갖지 않는다. 대신 exchange()를 통해서 결과를 반환한다.
public interface WebTestClient {
/* another mothod .. */
ResponseSpec exchange();
}
exchange의 반환값은 ResponseSpec이며, ReponseSpec은 status, header, cookie, body에 대한 assertions들을 제공하고, return Result를 통해서 결과를 FluxExchangeResult 형태로 반환할 수 있다.
public interface ResponseSpec {
ResponseSpec expectAll(ResponseSpecConsumer... consumers);
StatusAssertions expectStatus();
HeaderAssertions expectHeader();
CookieAssertions expectCookie();
<B> BodySpec<B, ?> expectBody(Class<B> bodyType);
<B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> bodyType);
<E> ListBodySpec<E> expectBodyList(Class<E> elementType);
<E> ListBodySpec<E> expectBodyList(ParameterizedTypeReference<E> elementType);
BodyContentSpec expectBody();
<T> FluxExchangeResult<T> returnResult(Class<T> elementClass);
<T> FluxExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef);
@FunctionalInterface
public interface ResponseSpecConsumer extends Consumer<ResponseSpec> {
}
}
WebTestClient status 검증
StatusAssertions에서는 http 상태를 검증한다.
httpStatus가 특정 값이랑 일치하는지 혹은 성공했는지를 검증하며, 기대와 다르다면 AssertionError throw하고 ResponseSpec을 반환하여 다른 Assertions를 체이닝할 수 있다.
주요 메서드는 isOk, isCreated, isAccepted, is2xxSuccessful, is4xxClientError 등.. 이 있다.
public WebTestClient.ResponseSpec isEqualTo(int status) {
return this.isEqualTo(HttpStatusCode.valueOf(status));
}
public WebTestClient.ResponseSpec isOk() {
return this.assertStatusAndReturn(HttpStatus.OK);
}
public WebTestClient.ResponseSpec isCreated() {
return this.assertStatusAndReturn(HttpStatus.CREATED);
}
public WebTestClient.ResponseSpec isAccepted() {
return this.assertStatusAndReturn(HttpStatus.ACCEPTED);
}
public WebTestClient.ResponseSpec isNoContent() {
return this.assertStatusAndReturn(HttpStatus.NO_CONTENT);
}
public WebTestClient.ResponseSpec isFound() {
return this.assertStatusAndReturn(HttpStatus.FOUND);
}
public WebTestClient.ResponseSpec isSeeOther() {
return this.assertStatusAndReturn(HttpStatus.SEE_OTHER);
}
public WebTestClient.ResponseSpec isNotModified() {
return this.assertStatusAndReturn(HttpStatus.NOT_MODIFIED);
}
WebTestClient로 요청을 보내고 응답값이 200, 2xx인지 확인하는 예제코드이다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
classes = {
GreetingController.class,
}
)
public class WebTestClientStatusExampleTest {
@MockBean
GreetingService mockGreetingService;
@Autowired
GreetingController greetingController;
WebTestClient webTestClient;
@BeforeEach
void setup() {
webTestClient = WebTestClient.bindToController(
greetingController
).build();
}
@Test
void when_call_then_return_greeting() {
// given
String message = "msg";
when(mockGreetingService.greetingMono(anyString()))
.thenReturn(Mono.just(message));
// when
webTestClient.get()
.uri("/greeting?who=taeil")
.exchange() // exchange를 통해서 ResponseSpec을 반환받고,
.expectStatus() // 상태가 isOk(200) 인지 확인하고
.isOk()
.expectStatus() // isOk가 반환한 ResponseSpec을 통해서 다시
.is2xxSuccessful(); // 2xx로 성공했는지를 체크
}
}
WebTestClient header 검증
HeaderAssertions에서는 http header를 검증하는 메서드를 제공한다.
특정 headerName에 값을 포함하는지, 포함한다면 기대하는 값이랑 동일한지 확인하고 Content-*, Last-Modified, Cache-Control, Expires, Location등의 header를 바로 검증할 수 있는 편의 메서드도 제공한다.
public class HeaderAssertions {
/* another method ... */
public WebTestClient.ResponseSpec valueEquals(String headerName, String... values) {}
public WebTestClient.ResponseSpec valueEquals(String headerName, long value) {}
public WebTestClient.ResponseSpec valueEqualsDate(String headerName, long value) {}
public WebTestClient.ResponseSpec valueMatches(String name, String pattern) {}
public WebTestClient.ResponseSpec valuesMatch(String name, String... patterns) {}
/* another method ... */
}
valueEquals가 가변인자인 이유는 spring web에서 하나의 헤더 이름으로 여러번 요청을 중복으로 보내는것이 가능하기 때문이다.
헤더 검증 예제를 보면 새로운 Controller 메서드에서 header를 포함하여 응답을 반환하고 X-WHO, X-AGE에 대한 검증을 Header를 검증한다.
@GetMapping("/header")
public Mono<ResponseEntity<String>> greetingWithHeader(
@RequestParam String who,
@RequestParam Long age
) {
return greetingService.greetingMono(who)
.map(message -> ResponseEntity.ok()
.header("X-WHO", who)
.header("X-AGE", age.toString())
.body(message));
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
classes = {
GreetingController.class,
}
)
public class WebTestClientHeaderExampleTest {
@MockBean
GreetingService mockGreetingService;
@Autowired
GreetingController greetingController;
WebTestClient webTestClient;
@BeforeEach
void setup() {
webTestClient = WebTestClient.bindToController(
greetingController
).build();
}
@Test
void when_call_then_return_greeting() {
// given
String message = "msg";
when(mockGreetingService.greetingMono(anyString()))
.thenReturn(Mono.just(message));
// when
webTestClient.get()
.uri("/greeting/header?who=taeil&age=29")
.exchange()
.expectHeader() // headerAssertions에 접근
.contentType("text/plain;charset=UTF-8")//contentType체크
.expectHeader()
.exists("X-WHO")
.expectHeader()
.doesNotExist("X-EMAIL")
.expectHeader()
.value("X-WHO", header -> {
assertEquals("taeil", header);
})
.expectHeader()
.valueEquals("X-AGE", 20L);
}
}
WebTestClient cookie 검증
CookieAssertions 에서는 http cookie를 검증한다. Cookie의 값, maxAge, path, domain, secure, httpOnly, sameSite등을 검증할 수 있다.
public class CookieAssertions {
/* another method .. */
public WebTestClient.ReponseSpec valueEquals(String name, String value){}
public WebTestClient.ReponseSpec value(String name, Consumer<String> consumer){}
public WebTestClient.ReponseSpec exists(String name){}
public WebTestClient.ReponseSpec doesNotExist(String name){}
public WebTestClient.ReponseSpec maxAge(String name, Duration expected){}
public WebTestClient.ReponseSpec path(String name, Duration expected){}
public WebTestClient.ReponseSpec domain(String name, Duration expected){}
/* another method .. */
}
@GetMapping("cookie")
public Mono<String> greetingWithCookie(
@RequestParam String who,
ServerWebExchange exchange
){
// 쿠키 생성
var cookie = ResponseCookie.from("who", who)
.maxAge(3600)
.domain("taeil.kim")
.httpOnly(true)
.path("/")
.sameSite("None")
.secure(true)
.build();
// 만든 쿠키를 response에 세팅
exchange.getResponse().addCookie(cookie);
return greetingService.greetingMono(who);
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
classes = {
GreetingController.class,
}
)
public class WebTestClientCookieExampleTest {
@MockBean
GreetingService mockGreetingService;
@Autowired
GreetingController greetingController;
WebTestClient webTestClient;
@BeforeEach
void setup() {
webTestClient = WebTestClient.bindToController(
greetingController
).build();
}
@Test
void when_call_then_return_greeting() {
// given
String message = "msg";
when(mockGreetingService.greetingMono(anyString()))
.thenReturn(Mono.just(message));
// when
var cookieName = "who";
// exchange를 통해서 반환받은 쿠키에 대한 값을 검증
webTestClient.get()
.uri("/greeting/cookie?who=grizz")
.exchange()
.expectCookie().exists(cookieName)
.expectCookie().valueEquals(cookieName, "grizz")
.expectCookie().domain(cookieName, "grizz.kim")
.expectCookie().httpOnly(cookieName, true)
.expectCookie().path(cookieName, "/")
.expectCookie().secure(cookieName, true)
.expectCookie()
.maxAge(cookieName, Duration.ofSeconds(3600));
}
}
WebTestClient body 검증
http 응답 body를 검증한다. expectBody, expectBodyList는 주어진 Class 혹은 ParameterizedTypeReference로 body를 decode 후 검증한다.
만약 decode가 실패하면 검증에 실패하게 되고 expectBody는 BodyContentSpec을 반환한다.
바디가 어떤 타입으로 반환되는지, JSON으로 반환된다면 expectBody를 사용하고, 리스트 형태를 가진다면 expectBodyList를 사용하고, body 타입에 맞게 적절하게 사용하는게 중요하다.
interface ResponseSpec {
<B> BodySpec<B, ?> expectBody(Class<B> bodyType);
<B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> bodyType);
<E> ListBodySpec<E> expectBodyList(Class<E> elementType);
<E> ListBodySpec<E> expectBodyList(
ParameterizedTypeReference<E> elementType);
BodyContentSpec expectBody();
}
BodySpec은 isEqualTo, value 등으로 값을 비교하는 메서드를 제공한다.
consumeWith를 통해서 Consumer를 제공하여 결과를 인자로 받고 Assertions를 사용할 수 있고, returnResult를 통해서 결과를 EntityExchangeResult로 전달한다. EntityExchangeResult의 getReponseBody를 통해서 body에 직접 접근이 가능하며 ListBodySpec은 body가 list로 제공되었다는 가정하에 검증할 수 있다.
public interface BodySpec<B, S extends BodySpec<B, S>> {
<T extends S> T isEqualTo(B expected);
<T extends S> T value(Matcher<? super B> matcher);
<T extends S, R> T value(Function<B, R> bodyMapper, Matcher<? super R> matcher);
<T extends S> T value(Consumer<B> consumer);
<T extends S> T consumeWith(Consumer<EntityExchangeResult<B>> consumer);
EntityExchangeResult<B> returnResult();
}
BodyContentSpec은 body가 decode되지 않은 상황에서 검증할 수 있는 메서드를 제공한다.
consumeWith의 경우, body를 byte[] 형태로 제공하며, returnResult를 통해서 body를 byte[] 형태로 직접 접근할 수도 있다.
public interface BodyContentSpec {
EntityExchangeResult<Void> isEmpty();
default BodyContentSpec json(String expectedJson) {
return this.json(expectedJson, false);
}
BodyContentSpec json(String expectedJson, boolean strict);
BodyContentSpec xml(String expectedXml);
JsonPathAssertions jsonPath(String expression, Object... args);
default XpathAssertions xpath(String expression, Object... args) {
return this.xpath(expression, (Map)null, args);
}
XpathAssertions xpath(String expression, @Nullable Map<String, String> namespaces, Object... args);
BodyContentSpec consumeWith(Consumer<EntityExchangeResult<byte[]>> consumer);
EntityExchangeResult<byte[]> returnResult();
}
BodyContentSpec은 BodySpec보다 훨씬 열려있어서 원하는 타입별로 테스트를 할 수 있는 메서드들을 제공하는 인터페이스라고 생각하면 된다.
예제 코드를 살펴보면 expectBody를 통해서 body를 GreetingResponse 형태로 decode하고 isEqualTo를 통해서 동일한 객체인지를 비교한다. 추가로 value와 Consumer를 사용해서 더 복잡한 조건을 검증한다.
GreetingResponse 객체를 추가로 만든 이유는 동일한 객체인지 체크하기 위함이다.
@GetMapping("/body")
public Mono<GreetingResponse> greetingWithBody(
@RequestParam String who,
@RequestParam Long age
) {
return greetingService.greetingMono(who)
.map(message -> new GreetingResponse(message, age, who));
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration(
classes = {
GreetingController.class,
}
)
public class WebTestClientBodyExampleTest {
@MockBean
GreetingService mockGreetingService;
@Autowired
GreetingController greetingController;
WebTestClient webTestClient;
@BeforeEach
void setup() {
webTestClient = WebTestClient.bindToController(
greetingController
).build();
}
@EqualsAndHashCode
@Setter
@NoArgsConstructor
@AllArgsConstructor
static class GreetingResponse {
private String message;
private String who;
private int age;
}
@Test
void when_call_then_return_greeting() {
// given
String message = "msg";
when(mockGreetingService.greetingMono(anyString()))
.thenReturn(Mono.just(message));
// when
var expected = new GreetingResponse(message, "taeil", 29);
webTestClient.get()
.uri("/greeting/body?who=taeil&age=29")
.exchange()
.expectHeader()
.contentType(MediaType.APPLICATION_JSON)
.expectBody(GreetingResponse.class)
.isEqualTo(expected)
.value(greetingResponse -> {
assertTrue(greetingResponse.age > 0);
});
}
}
'테스트코드' 카테고리의 다른 글
sliceTest - ContextConfiguration, Mock Bean (1) | 2024.06.11 |
---|---|
Private Method Test (0) | 2024.05.29 |
unitTest - Reactor-test (0) | 2024.05.28 |
unitTest - Mockito (0) | 2024.05.26 |
JUnit - Assertions 조건부 실행 (0) | 2024.05.20 |