From 4b4201efa1b3f8f310ff5aef15555e0c20795b52 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 8 Feb 2017 16:48:46 -0500 Subject: [PATCH] WebFlux test support for server endpoints Issue: SPR-14590 --- build.gradle | 3 + .../test/util/AssertionErrors.java | 21 +- .../server/DefaultControllerSpec.java | 199 ++++++ .../reactive/server/DefaultWebClientSpec.java | 99 +++ .../reactive/server/DefaultWebTestClient.java | 295 +++++++++ .../server/DefaultWebTestClientBuilder.java | 55 ++ .../web/reactive/server/ExchangeActions.java | 135 ++++ .../web/reactive/server/ExchangeInfo.java | 119 ++++ .../reactive/server/HttpHandlerConnector.java | 125 ++++ .../server/LoggingExchangeConsumer.java | 106 ++++ .../server/MultiValueMapEntryAssertions.java | 107 ++++ .../server/ResponseContentAssertions.java | 54 ++ .../server/ResponseEntityAssertions.java | 105 ++++ .../ResponseEntityCollectionAssertions.java | 99 +++ .../server/ResponseHeadersAssertions.java | 80 +++ .../server/ResponseStatusAssertions.java | 594 ++++++++++++++++++ .../StringMultiValueMapEntryAssertions.java | 79 +++ .../web/reactive/server/WebTestClient.java | 477 ++++++++++++++ .../web/reactive/server/WiretapConnector.java | 102 +++ .../web/reactive/server/package-info.java | 5 + .../reactive/server/samples/ErrorTests.java | 66 ++ .../reactive/server/samples/HeaderTests.java | 63 ++ .../server/samples/ResponseEntityTests.java | 159 +++++ .../samples/bind/ApplicationContextTests.java | 79 +++ .../server/samples/bind/ControllerTests.java | 59 ++ .../server/samples/bind/HttpServerTests.java | 75 +++ .../samples/bind/RouterFunctionTests.java | 56 ++ 27 files changed, 3414 insertions(+), 2 deletions(-) create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultControllerSpec.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebClientSpec.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeActions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeInfo.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/LoggingExchangeConsumer.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/MultiValueMapEntryAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseContentAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityCollectionAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseHeadersAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseStatusAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/StringMultiValueMapEntryAssertions.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/package-info.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ErrorTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/HeaderTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ResponseEntityTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ApplicationContextTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ControllerTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/HttpServerTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/RouterFunctionTests.java diff --git a/build.gradle b/build.gradle index 35bba0f07e3..f5e6ffe470b 100644 --- a/build.gradle +++ b/build.gradle @@ -1066,6 +1066,7 @@ project("spring-test") { optional(project(":spring-orm")) optional(project(":spring-web")) optional(project(":spring-webmvc")) + optional(project(":spring-webflux")) optional(project(":spring-websocket")) optional("junit:junit:${junitVersion}") optional("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}") @@ -1094,6 +1095,7 @@ project("spring-test") { optional("com.jayway.jsonpath:json-path:2.2.0") optional("org.reactivestreams:reactive-streams") optional("io.projectreactor:reactor-core") + optional("io.projectreactor.addons:reactor-test") testCompile(project(":spring-context-support")) testCompile(project(":spring-oxm")) testCompile("javax.mail:javax.mail-api:${javamailVersion}") @@ -1118,6 +1120,7 @@ project("spring-test") { testCompile("org.apache.httpcomponents:httpclient:${httpclientVersion}") testCompile("javax.cache:cache-api:${cacheApiVersion}") testCompile('de.bechte.junit:junit-hierarchicalcontextrunner:4.12.1') + testCompile('io.projectreactor.ipc:reactor-netty') testRuntime("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}") testRuntime("org.apache.logging.log4j:log4j-jul:${log4jVersion}") // Java Util Logging for JUnit 5 testRuntime("org.ehcache:ehcache:${ehcache3Version}") diff --git a/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java b/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java index f07574e82e7..79649d7001f 100644 --- a/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java +++ b/spring-test/src/main/java/org/springframework/test/util/AssertionErrors.java @@ -67,10 +67,10 @@ public abstract class AssertionErrors { } /** - * Assert two objects are equal raise an {@link AssertionError} if not. + * Assert two objects are equal and raise an {@link AssertionError} if not. *

For example: *

-	 * assertEquals("Response header [" + name + "]", actual, expected);
+	 * assertEquals("Response header [" + name + "]", expected, actual);
 	 * 
* @param message describes the value being checked * @param expected the expected value @@ -82,4 +82,21 @@ public abstract class AssertionErrors { } } + /** + * Assert two objects are not equal and raise an {@link AssertionError} otherwise. + *

For example: + *

+	 * assertNotEquals("Response header [" + name + "]", expected, actual);
+	 * 
+ * @param message describes the value being checked + * @param expected the expected value + * @param actual the actual value + */ + public static void assertNotEquals(String message, Object expected, Object actual) { + if (ObjectUtils.nullSafeEquals(expected, actual)) { + throw new AssertionError(message + " was not expected to be:" + + "<" + ObjectUtils.nullSafeToString(actual) + ">"); + } + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultControllerSpec.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultControllerSpec.java new file mode 100644 index 00000000000..4156a791f26 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultControllerSpec.java @@ -0,0 +1,199 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.format.FormatterRegistry; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.config.CorsRegistry; +import org.springframework.web.reactive.config.DelegatingWebFluxConfiguration; +import org.springframework.web.reactive.config.PathMatchConfigurer; +import org.springframework.web.reactive.config.ViewResolverRegistry; +import org.springframework.web.reactive.config.WebFluxConfigurer; + +/** + * Default implementation of {@link WebTestClient.ControllerSpec}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultControllerSpec implements WebTestClient.ControllerSpec { + + private final List controllers; + + private final TestWebFluxConfigurer configurer = new TestWebFluxConfigurer(); + + + public DefaultControllerSpec(Object... controllers) { + Assert.isTrue(!ObjectUtils.isEmpty(controllers), "At least one controller is required"); + this.controllers = Arrays.asList(controllers); + } + + + @Override + public DefaultControllerSpec contentTypeResolver(Consumer consumer) { + this.configurer.contentTypeResolverConsumer = consumer; + return this; + } + + @Override + public DefaultControllerSpec corsMappings(Consumer consumer) { + this.configurer.corsRegistryConsumer = consumer; + return this; + } + + @Override + public DefaultControllerSpec pathMatching(Consumer consumer) { + this.configurer.pathMatchConsumer = consumer; + return this; + } + + @Override + public DefaultControllerSpec messageReaders(Consumer>> consumer) { + this.configurer.readersConsumer = consumer; + return this; + } + + @Override + public DefaultControllerSpec messageWriters(Consumer>> consumer) { + this.configurer.writersConsumer = consumer; + return this; + } + + @Override + public DefaultControllerSpec formatters(Consumer consumer) { + this.configurer.formattersConsumer = consumer; + return this; + } + + @Override + public DefaultControllerSpec validator(Validator validator) { + this.configurer.validator = validator; + return this; + } + + @Override + public DefaultControllerSpec viewResolvers(Consumer consumer) { + this.configurer.viewResolversConsumer = consumer; + return this; + } + + @Override + public WebTestClient.WebClientSpec webClientSpec() { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + this.controllers.forEach(controller -> registerBean(context, controller)); + context.register(DelegatingWebFluxConfiguration.class); + context.registerBean(WebFluxConfigurer.class, () -> this.configurer); + context.refresh(); + return WebTestClient.bindToApplicationContext(context); + } + + @SuppressWarnings("unchecked") + private void registerBean(AnnotationConfigApplicationContext context, T bean) { + context.registerBean((Class) bean.getClass(), () -> bean); + } + + @Override + public WebTestClient build() { + return webClientSpec().build(); + } + + + private class TestWebFluxConfigurer implements WebFluxConfigurer { + + private Consumer contentTypeResolverConsumer; + + private Consumer corsRegistryConsumer; + + private Consumer pathMatchConsumer; + + private Consumer>> readersConsumer; + + private Consumer>> writersConsumer; + + private Consumer formattersConsumer; + + private Validator validator; + + private Consumer viewResolversConsumer; + + + @Override + public void configureContentTypeResolver(RequestedContentTypeResolverBuilder builder) { + if (this.contentTypeResolverConsumer != null) { + this.contentTypeResolverConsumer.accept(builder); + } + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + if (this.corsRegistryConsumer != null) { + this.corsRegistryConsumer.accept(registry); + } + } + + @Override + public void configurePathMatching(PathMatchConfigurer configurer) { + if (this.pathMatchConsumer != null) { + this.pathMatchConsumer.accept(configurer); + } + } + + @Override + public void extendMessageReaders(List> readers) { + if (this.readersConsumer != null) { + this.readersConsumer.accept(readers); + } + } + + @Override + public void extendMessageWriters(List> writers) { + if (this.writersConsumer != null) { + this.writersConsumer.accept(writers); + } + } + + @Override + public void addFormatters(FormatterRegistry registry) { + if (this.formattersConsumer != null) { + this.formattersConsumer.accept(registry); + } + } + + @Override + public Optional getValidator() { + return Optional.ofNullable(this.validator); + } + + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + if (this.viewResolversConsumer != null) { + this.viewResolversConsumer.accept(registry); + } + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebClientSpec.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebClientSpec.java new file mode 100644 index 00000000000..835c3ac8834 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebClientSpec.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.util.Map; + +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriBuilderFactory; + +/** + * Default implementation of {@link WebTestClient.WebClientSpec}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultWebClientSpec implements WebTestClient.WebClientSpec { + + private final WebClient.Builder builder = WebClient.builder(); + + private final ClientHttpConnector connector; + + + public DefaultWebClientSpec() { + this(new ReactorClientHttpConnector()); + } + + public DefaultWebClientSpec(HttpHandler httpHandler) { + this(new HttpHandlerConnector(httpHandler)); + } + + public DefaultWebClientSpec(ClientHttpConnector connector) { + this.connector = connector; + } + + + @Override + public WebTestClient.WebClientSpec baseUrl(String baseUrl) { + this.builder.baseUrl(baseUrl); + return this; + } + + @Override + public WebTestClient.WebClientSpec defaultUriVariables(Map defaultUriVariables) { + this.builder.defaultUriVariables(defaultUriVariables); + return this; + } + + @Override + public WebTestClient.WebClientSpec uriBuilderFactory(UriBuilderFactory uriBuilderFactory) { + this.builder.uriBuilderFactory(uriBuilderFactory); + return this; + } + + @Override + public WebTestClient.WebClientSpec defaultHeader(String headerName, String... headerValues) { + this.builder.defaultHeader(headerName, headerValues); + return this; + } + + @Override + public WebTestClient.WebClientSpec defaultCookie(String cookieName, String... cookieValues) { + this.builder.defaultCookie(cookieName, cookieValues); + return this; + } + + @Override + public WebTestClient.WebClientSpec exchangeStrategies(ExchangeStrategies strategies) { + this.builder.exchangeStrategies(strategies); + return this; + } + + @Override + public WebTestClient.Builder builder() { + return new DefaultWebTestClientBuilder(this.builder, this.connector); + } + + @Override + public WebTestClient build() { + return builder().build(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java new file mode 100644 index 00000000000..f1d7b6f1911 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -0,0 +1,295 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriBuilder; + +/** + * Default implementation of {@link WebTestClient}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultWebTestClient implements WebTestClient { + + private final WebClient webClient; + + private final Duration responseTimeout; + + private final WiretapConnectorListener connectorListener; + + + DefaultWebTestClient(WebClient.Builder webClientBuilder, ClientHttpConnector connector, Duration timeout) { + Assert.notNull(webClientBuilder, "WebClient.Builder is required"); + + WiretapConnector wiretapConnector = new WiretapConnector(connector); + webClientBuilder.clientConnector(wiretapConnector); + + this.connectorListener = new WiretapConnectorListener(); + wiretapConnector.addListener(this.connectorListener); + + this.webClient = webClientBuilder.build(); + this.responseTimeout = (timeout != null ? timeout : Duration.ofSeconds(5)); + } + + private DefaultWebTestClient(DefaultWebTestClient webTestClient, ExchangeFilterFunction filter) { + this.webClient = webTestClient.webClient.filter(filter); + this.connectorListener = webTestClient.connectorListener; + this.responseTimeout = webTestClient.responseTimeout; + } + + + private Duration getTimeout() { + return this.responseTimeout; + } + + + @Override + public UriSpec get() { + return toUriSpec(WebClient::get); + } + + @Override + public UriSpec head() { + return toUriSpec(WebClient::head); + } + + @Override + public UriSpec post() { + return toUriSpec(WebClient::post); + } + + @Override + public UriSpec put() { + return toUriSpec(WebClient::put); + } + + @Override + public UriSpec patch() { + return toUriSpec(WebClient::patch); + } + + @Override + public UriSpec delete() { + return toUriSpec(WebClient::delete); + } + + @Override + public UriSpec options() { + return toUriSpec(WebClient::options); + } + + private UriSpec toUriSpec(Function function) { + return new DefaultUriSpec(function.apply(this.webClient)); + } + + + @Override + public WebTestClient filter(ExchangeFilterFunction filter) { + return new DefaultWebTestClient(this, filter); + } + + + + private class DefaultUriSpec implements UriSpec { + + private final WebClient.UriSpec uriSpec; + + + DefaultUriSpec(WebClient.UriSpec spec) { + this.uriSpec = spec; + } + + @Override + public HeaderSpec uri(URI uri) { + return new DefaultHeaderSpec(this.uriSpec.uri(uri)); + } + + @Override + public HeaderSpec uri(String uriTemplate, Object... uriVariables) { + return new DefaultHeaderSpec(this.uriSpec.uri(uriTemplate, uriVariables)); + } + + @Override + public HeaderSpec uri(String uriTemplate, Map uriVariables) { + return new DefaultHeaderSpec(this.uriSpec.uri(uriTemplate, uriVariables)); + } + + @Override + public HeaderSpec uri(Function uriBuilder) { + return new DefaultHeaderSpec(this.uriSpec.uri(uriBuilder)); + } + } + + private class DefaultHeaderSpec implements WebTestClient.HeaderSpec { + + private final WebClient.HeaderSpec headerSpec; + + private final String requestId; + + + DefaultHeaderSpec(WebClient.HeaderSpec spec) { + this.headerSpec = spec; + this.requestId = connectorListener.registerRequestId(spec); + } + + + @Override + public DefaultHeaderSpec header(String headerName, String... headerValues) { + this.headerSpec.header(headerName, headerValues); + return this; + } + + @Override + public DefaultHeaderSpec headers(HttpHeaders headers) { + this.headerSpec.headers(headers); + return this; + } + + @Override + public DefaultHeaderSpec accept(MediaType... acceptableMediaTypes) { + this.headerSpec.accept(acceptableMediaTypes); + return this; + } + + @Override + public DefaultHeaderSpec acceptCharset(Charset... acceptableCharsets) { + this.headerSpec.acceptCharset(acceptableCharsets); + return this; + } + + @Override + public DefaultHeaderSpec contentType(MediaType contentType) { + this.headerSpec.contentType(contentType); + return this; + } + + @Override + public DefaultHeaderSpec contentLength(long contentLength) { + this.headerSpec.contentLength(contentLength); + return this; + } + + @Override + public DefaultHeaderSpec cookie(String name, String value) { + this.headerSpec.cookie(name, value); + return this; + } + + @Override + public DefaultHeaderSpec cookies(MultiValueMap cookies) { + this.headerSpec.cookies(cookies); + return this; + } + + @Override + public DefaultHeaderSpec ifModifiedSince(ZonedDateTime ifModifiedSince) { + this.headerSpec.ifModifiedSince(ifModifiedSince); + return this; + } + + @Override + public DefaultHeaderSpec ifNoneMatch(String... ifNoneMatches) { + this.headerSpec.ifNoneMatch(ifNoneMatches); + return this; + } + + + @Override + public ExchangeActions exchange() { + return getExchangeActions(this.headerSpec.exchange()); + } + + @Override + public ExchangeActions exchange(BodyInserter inserter) { + return getExchangeActions(this.headerSpec.exchange(inserter)); + } + + @Override + public > ExchangeActions exchange(S publisher, Class elementClass) { + return getExchangeActions(this.headerSpec.exchange(publisher, elementClass)); + } + + private ExchangeActions getExchangeActions(Mono responseMono) { + ClientResponse response = responseMono.block(getTimeout()); + ExchangeInfo info = getExchangeInfo(response); + return new ExchangeActions(info); + } + + private ExchangeInfo getExchangeInfo(ClientResponse clientResponse) { + WiretapConnector.Info wiretapInfo = connectorListener.retrieveRequest(this.requestId); + ClientHttpRequest request = wiretapInfo.getRequest(); + return new ExchangeInfo(request.getMethod(), request.getURI(), request.getHeaders(), + clientResponse, getTimeout()); + } + + } + + + private static class WiretapConnectorListener implements Consumer { + + private static final String REQUEST_ID_HEADER_NAME = "request-id"; + + + private final AtomicLong index = new AtomicLong(); + + private final Map exchanges = new ConcurrentHashMap<>(); + + + public String registerRequestId(WebClient.HeaderSpec headerSpec) { + String requestId = String.valueOf(this.index.incrementAndGet()); + headerSpec.header(REQUEST_ID_HEADER_NAME, requestId); + return requestId; + } + + @Override + public void accept(WiretapConnector.Info info) { + Optional.ofNullable(info.getRequest().getHeaders().getFirst(REQUEST_ID_HEADER_NAME)) + .ifPresent(id -> this.exchanges.put(id, info)); + } + + public WiretapConnector.Info retrieveRequest(String requestId) { + WiretapConnector.Info info = this.exchanges.remove(requestId); + Assert.notNull(info, "No match for \"request-id\"=" + requestId); + return info; + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java new file mode 100644 index 00000000000..2c3389659ba --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClientBuilder.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.time.Duration; + +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Default implementation of {@link WebTestClient.Builder}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class DefaultWebTestClientBuilder implements WebTestClient.Builder { + + private final WebClient.Builder webClientBuilder; + + private final ClientHttpConnector connector; + + private Duration responseTimeout; + + + public DefaultWebTestClientBuilder(WebClient.Builder builder, ClientHttpConnector connector) { + this.webClientBuilder = builder; + this.connector = connector; + } + + + @Override + public WebTestClient.Builder responseTimeout(Duration timeout) { + this.responseTimeout = timeout; + return this; + } + + @Override + public WebTestClient build() { + return new DefaultWebTestClient(this.webClientBuilder, this.connector, this.responseTimeout); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeActions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeActions.java new file mode 100644 index 00000000000..69b943a2d6f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeActions.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.function.Consumer; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.AssertionErrors; +import org.springframework.web.reactive.function.client.ClientResponse; + +/** + * An API to apply assertion and other actions against a performed exchange. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public final class ExchangeActions { + + private final ExchangeInfo exchangeInfo; + + + public ExchangeActions(ExchangeInfo info) { + this.exchangeInfo = info; + } + + + private ClientResponse getResponse() { + return this.exchangeInfo.getResponse(); + } + + private Duration getResponseTimeout() { + return this.exchangeInfo.getResponseTimeout(); + } + + + /** + * Assert the status of the response. + * @return further options for asserting the status of the response + */ + public ResponseStatusAssertions assertStatus() { + return new ResponseStatusAssertions(this, this.exchangeInfo); + } + + /** + * Assert specific, commonly used response headers. + * @return further options for asserting headers + */ + public ResponseHeadersAssertions assertHeaders() { + return new ResponseHeadersAssertions(this, getResponse().headers().asHttpHeaders()); + } + + /** + * Assert options for any response header specified by name. + * @return options for asserting headers + */ + public StringMultiValueMapEntryAssertions assertHeader(String headerName) { + HttpHeaders headers = getResponse().headers().asHttpHeaders(); + return new StringMultiValueMapEntryAssertions(this, headerName, headers, "Response header"); + } + + /** + * Assert the response is empty. + */ + public void assertNoContent() { + Flux body = getResponse().bodyToFlux(ByteBuffer.class); + StepVerifier.create(body).expectComplete().verify(getResponseTimeout()); + } + + /** + * Assert the content of the response. + * @param entityType the type of entity to decode the response as + * @param the type of entity + * @return further options for asserting response entities + */ + public ResponseContentAssertions assertEntity(Class entityType) { + return new ResponseContentAssertions(this.exchangeInfo, ResolvableType.forClass(entityType)); + } + + /** + * Variant of {@link #assertEntity(Class)} with a {@link ResolvableType}. + * @param entityType the type of entity to decode the response as + * @return further options for asserting response entities + */ + public ResponseContentAssertions assertEntity(ResolvableType entityType) { + return new ResponseContentAssertions(this.exchangeInfo, entityType); + } + + /** + * Log debug information about the exchange. + */ + public LoggingExchangeConsumer log() { + return new LoggingExchangeConsumer(this, this.exchangeInfo); + } + + /** + * Apply custom assertions on the performed exchange with the help of + * {@link AssertionErrors} or an assertion library such as AssertJ. + *

Consider using statically imported methods to improve readability + * @param consumer consumer that will apply assertions. + */ + public ExchangeActions andAssert(Consumer consumer) { + consumer.accept(this.exchangeInfo); + return this; + } + + /** + * Apply custom actions on the performed exchange. + *

Consider using statically imported methods to improve readability + * @param consumer consumer that will apply the custom action + */ + public ExchangeActions andDo(Consumer consumer) { + consumer.accept(this.exchangeInfo); + return this; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeInfo.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeInfo.java new file mode 100644 index 00000000000..a33d211acf2 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeInfo.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.net.URI; +import java.time.Duration; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientResponse; + +/** + * Contains information about a performed exchange. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ExchangeInfo { + + private final HttpMethod method; + + private final URI url; + + private final HttpHeaders requestHeaders; + + private final ClientResponse response; + + private final Duration responseTimeout; + + + public ExchangeInfo(HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, + ClientResponse response, Duration responseTimeout) { + + this.method = httpMethod; + this.url = uri; + this.requestHeaders = requestHeaders; + this.response = response; + this.responseTimeout = responseTimeout; + } + + + /** + * Return the HTTP method of the exchange. + */ + public HttpMethod getHttpMethod() { + return this.method; + } + + /** + * Return the URI of the exchange. + */ + public URI getUrl() { + return this.url; + } + + /** + * Return the request headers of the exchange. + */ + public HttpHeaders getRequestHeaders() { + return this.requestHeaders; + } + + /** + * Return the {@link ClientResponse} for the exchange. + */ + public ClientResponse getResponse() { + return this.response; + } + + /** + * Return the configured timeout for blocking on response data. + */ + public Duration getResponseTimeout() { + return this.responseTimeout; + } + + + @Override + public String toString() { + HttpStatus status = getResponse().statusCode(); + return "\n\n" + + formatValue("Request", getHttpMethod() + " " + getUrl()) + + formatValue("Status", status + " " + status.getReasonPhrase()) + + formatHeading("Response Headers") + + formatHeaders(getResponse().headers().asHttpHeaders()) + + formatHeading("Request Headers") + + formatHeaders(getRequestHeaders()); + } + + private String formatHeading(String heading) { + return "\n" + String.format("%s", heading) + "\n"; + } + + private String formatValue(String label, Object value) { + return String.format("%18s: %s", label, value) + "\n"; + } + + private String formatHeaders(HttpHeaders headers) { + return headers.entrySet().stream() + .map(entry -> formatValue(entry.getKey(), entry.getValue())) + .collect(Collectors.joining()); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java new file mode 100644 index 00000000000..a6b500159bc --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HttpHandlerConnector.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.net.URI; +import java.util.Optional; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpResponse; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; + +/** + * Connector that handles requests by invoking an {@link HttpHandler} rather + * than making actual requests to a network socket. + * + *

Internally the connector uses and adapts
+ * {@link MockClientHttpRequest} and {@link MockClientHttpResponse} to
+ * {@link MockServerHttpRequest} and {@link MockServerHttpResponse}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class HttpHandlerConnector implements ClientHttpConnector { + + private static Log logger = LogFactory.getLog(HttpHandlerConnector.class); + + + private final HttpHandler handler; + + + /** + * Constructor with the {@link HttpHandler} to handle requests with. + */ + public HttpHandlerConnector(HttpHandler handler) { + Assert.notNull(handler, "HttpHandler is required"); + this.handler = handler; + } + + + @Override + public Mono connect(HttpMethod httpMethod, URI uri, + Function> requestCallback) { + + MonoProcessor result = MonoProcessor.create(); + + MockClientHttpRequest mockClientRequest = new MockClientHttpRequest(httpMethod, uri); + MockServerHttpResponse mockServerResponse = new MockServerHttpResponse(); + + mockClientRequest.setWriteHandler(requestBody -> { + log("Invoking HttpHandler for ", httpMethod, uri); + ServerHttpRequest mockServerRequest = adaptRequest(mockClientRequest, requestBody); + this.handler.handle(mockServerRequest, mockServerResponse).subscribe(aVoid -> {}, result::onError); + return Mono.empty(); + }); + + mockServerResponse.setWriteHandler(responseBody -> { + log("Creating client response for ", httpMethod, uri); + result.onNext(adaptResponse(mockServerResponse, responseBody)); + return Mono.empty(); + }); + + log("Writing client request for ", httpMethod, uri); + requestCallback.apply(mockClientRequest).subscribe(aVoid -> {}, result::onError); + + return result; + } + + private void log(String message, HttpMethod httpMethod, URI uri) { + if (logger.isDebugEnabled()) { + logger.debug(String.format("%s %s \"%s\"", message, httpMethod, uri)); + } + } + + private ServerHttpRequest adaptRequest(MockClientHttpRequest request, Publisher body) { + HttpMethod method = request.getMethod(); + URI uri = request.getURI(); + HttpHeaders headers = request.getHeaders(); + MultiValueMap cookies = request.getCookies(); + return MockServerHttpRequest.method(method, uri).headers(headers).cookies(cookies).body(body); + } + + private ClientHttpResponse adaptResponse(MockServerHttpResponse response, Flux body) { + HttpStatus status = Optional.ofNullable(response.getStatusCode()).orElse(HttpStatus.OK); + MockClientHttpResponse clientResponse = new MockClientHttpResponse(status); + clientResponse.getHeaders().putAll(response.getHeaders()); + clientResponse.getCookies().putAll(response.getCookies()); + clientResponse.setBody(body); + return clientResponse; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/LoggingExchangeConsumer.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/LoggingExchangeConsumer.java new file mode 100644 index 00000000000..65f9e5ef925 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/LoggingExchangeConsumer.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.io.Writer; +import java.util.function.BiConsumer; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * Provides options for logging information about the performed exchange. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class LoggingExchangeConsumer { + + private static Log logger = LogFactory.getLog(LoggingExchangeConsumer.class); + + + private final ExchangeActions exchangeActions; + + private final ExchangeInfo exchangeInfo; + + + public LoggingExchangeConsumer(ExchangeActions exchangeActions, ExchangeInfo exchangeInfo) { + this.exchangeActions = exchangeActions; + this.exchangeInfo = exchangeInfo; + } + + + /** + * Log with {@link System#out}. + */ + public ExchangeActions toConsole() { + System.out.println(this.exchangeInfo.toString()); + return this.exchangeActions; + } + + /** + * Log with a given {@link OutputStream}. + */ + public ExchangeActions toOutputStream(OutputStream stream) { + return toWriter(new PrintWriter(stream, true)); + } + + /** + * Log with a given {@link Writer}. + */ + public ExchangeActions toWriter(Writer writer) { + try { + writer.write(this.exchangeInfo.toString()); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to print exchange info", ex); + } + return this.exchangeActions; + } + + /** + * Log if TRACE level logging is enabled. + */ + public ExchangeActions ifTraceEnabled() { + return doLog(Log::isTraceEnabled, Log::trace); + } + + /** + * Log if DEBUG level logging is enabled. + */ + public ExchangeActions ifDebugEnabled() { + return doLog(Log::isDebugEnabled, Log::debug); + } + + /** + * Log if INFO level logging is enabled. + */ + public ExchangeActions ifInfoEnabled() { + return doLog(Log::isInfoEnabled, Log::info); + } + + private ExchangeActions doLog(Predicate logLevelPredicate, BiConsumer logAction) { + if (logLevelPredicate.test(logger)) { + logAction.accept(logger, this.exchangeInfo.toString()); + } + return this.exchangeActions; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/MultiValueMapEntryAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/MultiValueMapEntryAssertions.java new file mode 100644 index 00000000000..0a085408fc8 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/MultiValueMapEntryAssertions.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.util.MultiValueMap; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on the values of a {@link MultiValueMap} entry. + * + * @param the type of values in the map. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class MultiValueMapEntryAssertions { + + private final ExchangeActions exchangeActions; + + private final String name; + + private final MultiValueMap map; + + private final String errorMessagePrefix; + + + MultiValueMapEntryAssertions(ExchangeActions actions, String name, + MultiValueMap map, String errorMessagePrefix) { + + this.exchangeActions = actions; + this.name = name; + this.map = map; + this.errorMessagePrefix = errorMessagePrefix + " " + this.name; + } + + + /** + * The given values are equal to the actual values. + * @param values the values to match + */ + @SuppressWarnings("unchecked") + public ExchangeActions isEqualTo(V... values) { + List actual = this.map.get(this.name); + assertEquals(this.errorMessagePrefix, Arrays.asList(values), actual); + return this.exchangeActions; + } + + /** + * The list of actual values contains the given values. + * @param values the values to match + */ + @SuppressWarnings("unchecked") + public ExchangeActions hasValues(V... values) { + List actual = this.map.get(this.name); + List expected = Arrays.asList(values); + String message = getErrorMessagePrefix() + " does not contain " + expected; + assertTrue(message, actual.containsAll(expected)); + return this.exchangeActions; + } + + + // Protected methods for sub-classes + + protected ExchangeActions getExchangeActions() { + return this.exchangeActions; + } + + protected String getName() { + return this.name; + } + + protected MultiValueMap getMap() { + return this.map; + } + + protected String getErrorMessagePrefix() { + return this.errorMessagePrefix; + } + + protected V getValue(int index) { + List actualValues = getMap().get(getName()); + if (actualValues == null || index >= actualValues.size()) { + fail(getErrorMessagePrefix() + " does not have values at index[" + index + "]: " + actualValues); + } + return actualValues.get(index); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseContentAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseContentAssertions.java new file mode 100644 index 00000000000..1548d7c7199 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseContentAssertions.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.core.ResolvableType; + +import static org.springframework.web.reactive.function.BodyExtractors.toFlux; + +/** + * Provides options for asserting the content of the response. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResponseContentAssertions extends ResponseEntityAssertions { + + + ResponseContentAssertions(ExchangeInfo exchangeInfo, ResolvableType entityType) { + super(exchangeInfo, entityType); + } + + + /** + * Assert the response as a collection of entities. + */ + public ResponseEntityCollectionAssertions collection() { + return new ResponseEntityCollectionAssertions<>(getExchangeInfo(), getEntityType()); + } + + /** + * Assert the response using a {@link StepVerifier}. + */ + public StepVerifier.FirstStep stepVerifier() { + Flux flux = getExchangeInfo().getResponse().body(toFlux(getEntityType())); + return StepVerifier.create(flux); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityAssertions.java new file mode 100644 index 00000000000..d40c72d2fc7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityAssertions.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.util.function.Consumer; + +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.test.util.AssertionErrors; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.web.reactive.function.BodyExtractors.toMono; + +/** + * Provides methods for asserting the response body as a single entity. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResponseEntityAssertions { + + private final ExchangeInfo exchangeInfo; + + private final ResolvableType entityType; + + private T entity; + + + ResponseEntityAssertions(ExchangeInfo info, ResolvableType entityType) { + this.exchangeInfo = info; + this.entityType = entityType; + } + + + protected ExchangeInfo getExchangeInfo() { + return this.exchangeInfo; + } + + protected ResolvableType getEntityType() { + return this.entityType; + } + + private T resolveEntity() { + if (this.entity == null) { + Mono mono = this.exchangeInfo.getResponse().body(toMono(entityType)); + this.entity = mono.block(this.exchangeInfo.getResponseTimeout()); + } + return this.entity; + } + + + /** + * Assert the response entity is equal to the given expected entity. + */ + public ResponseEntityAssertions isEqualTo(T expected) { + assertEquals("Response body", expected, resolveEntity()); + return this; + } + + /** + * Assert the response entity is not equal to the given expected entity. + */ + public ResponseEntityAssertions isNotEqualTo(T expected) { + assertEquals("Response body", expected, resolveEntity()); + return this; + } + + /** + * Apply custom assertions on the response entity with the help of + * {@link AssertionErrors} or an assertion library such as AssertJ. + *

Consider using statically imported methods for creating the assertion + * consumer to improve readability of tests. + * @param assertionConsumer consumer that will apply assertions. + */ + public ResponseEntityAssertions andAssert(Consumer assertionConsumer) { + assertionConsumer.accept(resolveEntity()); + return this; + } + + /** + * Apply custom actions on the response entity collection. + *

Consider using statically imported methods for creating the assertion + * consumer to improve readability of tests. + * @param consumer consumer that will apply the custom action + */ + public ResponseEntityAssertions andDo(Consumer consumer) { + consumer.accept(resolveEntity()); + return this; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityCollectionAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityCollectionAssertions.java new file mode 100644 index 00000000000..047bc43af36 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseEntityCollectionAssertions.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Consumer; + +import reactor.core.publisher.Flux; + +import org.springframework.core.ResolvableType; +import org.springframework.test.util.AssertionErrors; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.web.reactive.function.BodyExtractors.toFlux; + +/** + * Provides methods for asserting the response body as a collection of entities. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResponseEntityCollectionAssertions { + + private final Collection entities; + + + ResponseEntityCollectionAssertions(ExchangeInfo info, ResolvableType entityType) { + Flux flux = info.getResponse().body(toFlux(entityType)); + this.entities = flux.collectList().block(info.getResponseTimeout()); + } + + + /** + * Assert the number of entities. + */ + public ResponseEntityCollectionAssertions hasSize(int size) { + assertEquals("Response entities count", size, this.entities.size()); + return this; + } + + /** + * Assert that the response contains all of the given entities. + */ + @SuppressWarnings("unchecked") + public ResponseEntityCollectionAssertions contains(T... entities) { + Arrays.stream(entities).forEach(entity -> + assertTrue("Response does not contain " + entity, this.entities.contains(entity))); + return this; + } + + /** + * Assert that the response does not contain any of the given entities. + */ + @SuppressWarnings("unchecked") + public ResponseEntityCollectionAssertions doesNotContain(T... entities) { + Arrays.stream(entities).forEach(entity -> + assertTrue("Response should not contain " + entity, !this.entities.contains(entity))); + return this; + } + + /** + * Apply custom assertions on the response entity collection with the help of + * {@link AssertionErrors} or an assertion library such as AssertJ. + *

Consider using statically imported methods for creating the assertion + * consumer to improve readability of tests. + * @param assertionConsumer consumer that will apply assertions. + */ + public ResponseEntityCollectionAssertions andAssert(Consumer> assertionConsumer) { + assertionConsumer.accept(this.entities); + return this; + } + + /** + * Apply custom actions on the response entity collection. + *

Consider using statically imported methods for creating the assertion + * consumer to improve readability of tests. + * @param consumer consumer that will apply the custom action + */ + public ResponseEntityCollectionAssertions andDo(Consumer> consumer) { + consumer.accept(this.entities); + return this; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseHeadersAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseHeadersAssertions.java new file mode 100644 index 00000000000..519f5a46101 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseHeadersAssertions.java @@ -0,0 +1,80 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import static org.springframework.test.util.AssertionErrors.assertEquals; + +/** + * Provides methods for asserting specific, commonly used response headers. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResponseHeadersAssertions { + + private final ExchangeActions exchangeActions; + + private final HttpHeaders headers; + + + ResponseHeadersAssertions(ExchangeActions actions, HttpHeaders headers) { + this.exchangeActions = actions; + this.headers = headers; + } + + + public ExchangeActions cacheControl(CacheControl cacheControl) { + String actual = this.headers.getCacheControl(); + assertEquals("Response header Cache-Control", cacheControl.getHeaderValue(), actual); + return this.exchangeActions; + } + + public ExchangeActions contentDisposition(ContentDisposition contentDisposition) { + ContentDisposition actual = this.headers.getContentDisposition(); + assertEquals("Response header Content-Disposition", contentDisposition, actual); + return this.exchangeActions; + } + + public ExchangeActions contentLength(long contentLength) { + long actual = this.headers.getContentLength(); + assertEquals("Response header Content-Length", contentLength, actual); + return this.exchangeActions; + } + + public ExchangeActions contentType(MediaType mediaType) { + MediaType actual = this.headers.getContentType(); + assertEquals("Response header Content-Type", mediaType, actual); + return this.exchangeActions; + } + + public ExchangeActions expires(int expires) { + long actual = this.headers.getExpires(); + assertEquals("Response header Expires", expires, actual); + return this.exchangeActions; + } + + public ExchangeActions lastModified(int lastModified) { + long actual = this.headers.getLastModified(); + assertEquals("Response header Last-Modified", lastModified, actual); + return this.exchangeActions; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseStatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseStatusAssertions.java new file mode 100644 index 00000000000..387392f4930 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ResponseStatusAssertions.java @@ -0,0 +1,594 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import org.springframework.http.HttpStatus; + +import static org.springframework.test.util.AssertionErrors.assertEquals; + +/** + * Provides methods for asserting the response status. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +@SuppressWarnings("unused") +public class ResponseStatusAssertions { + + private final ExchangeActions exchangeActions; + + private final HttpStatus httpStatus; + + + ResponseStatusAssertions(ExchangeActions actions, ExchangeInfo info) { + this.exchangeActions = actions; + this.httpStatus = info.getResponse().statusCode(); + } + + + public ExchangeActions is(int status) { + assertEquals("Response status", status, this.httpStatus.value()); + return this.exchangeActions; + } + + /** + * Assert the response status code is in the 1xx range. + */ + public ExchangeActions is1xxInformational() { + String message = "Range for response status value " + this.httpStatus; + assertEquals(message, HttpStatus.Series.INFORMATIONAL, this.httpStatus.series()); + return this.exchangeActions; + } + + /** + * Assert the response status code is in the 2xx range. + */ + public ExchangeActions is2xxSuccessful() { + String message = "Range for response status value " + this.httpStatus; + assertEquals(message, HttpStatus.Series.SUCCESSFUL, this.httpStatus.series()); + return this.exchangeActions; + } + + /** + * Assert the response status code is in the 3xx range. + */ + public ExchangeActions is3xxRedirection() { + String message = "Range for response status value " + this.httpStatus; + assertEquals(message, HttpStatus.Series.REDIRECTION, this.httpStatus.series()); + return this.exchangeActions; + } + + /** + * Assert the response status code is in the 4xx range. + */ + public ExchangeActions is4xxClientError() { + String message = "Range for response status value " + this.httpStatus; + assertEquals(message, HttpStatus.Series.CLIENT_ERROR, this.httpStatus.series()); + return this.exchangeActions; + } + + /** + * Assert the response status code is in the 5xx range. + */ + public ExchangeActions is5xxServerError() { + String message = "Range for response status value " + this.httpStatus; + assertEquals(message, HttpStatus.Series.SERVER_ERROR, this.httpStatus.series()); + return this.exchangeActions; + } + + /** + * Assert the response error message. + */ + public ExchangeActions reason(String reason) { + assertEquals("Response status reason", reason, this.httpStatus.getReasonPhrase()); + return this.exchangeActions; + } + + /** + * Assert the response status code is {@code HttpStatus.CONTINUE} (100). + */ + public ExchangeActions isContinue() { + return doMatch(HttpStatus.CONTINUE); + } + + /** + * Assert the response status code is {@code HttpStatus.SWITCHING_PROTOCOLS} (101). + */ + public ExchangeActions isSwitchingProtocols() { + return doMatch(HttpStatus.SWITCHING_PROTOCOLS); + } + + /** + * Assert the response status code is {@code HttpStatus.PROCESSING} (102). + */ + public ExchangeActions isProcessing() { + return doMatch(HttpStatus.PROCESSING); + } + + /** + * Assert the response status code is {@code HttpStatus.CHECKPOINT} (103). + */ + public ExchangeActions isCheckpoint() { + return doMatch(HttpStatus.valueOf(103)); + } + + /** + * Assert the response status code is {@code HttpStatus.OK} (200). + */ + public ExchangeActions isOk() { + return doMatch(HttpStatus.OK); + } + + /** + * Assert the response status code is {@code HttpStatus.CREATED} (201). + */ + public ExchangeActions isCreated() { + return doMatch(HttpStatus.CREATED); + } + + /** + * Assert the response status code is {@code HttpStatus.ACCEPTED} (202). + */ + public ExchangeActions isAccepted() { + return doMatch(HttpStatus.ACCEPTED); + } + + /** + * Assert the response status code is {@code HttpStatus.NON_AUTHORITATIVE_INFORMATION} (203). + */ + public ExchangeActions isNonAuthoritativeInformation() { + return doMatch(HttpStatus.NON_AUTHORITATIVE_INFORMATION); + } + + /** + * Assert the response status code is {@code HttpStatus.NO_CONTENT} (204). + */ + public ExchangeActions isNoContent() { + return doMatch(HttpStatus.NO_CONTENT); + } + + /** + * Assert the response status code is {@code HttpStatus.RESET_CONTENT} (205). + */ + public ExchangeActions isResetContent() { + return doMatch(HttpStatus.RESET_CONTENT); + } + + /** + * Assert the response status code is {@code HttpStatus.PARTIAL_CONTENT} (206). + */ + public ExchangeActions isPartialContent() { + return doMatch(HttpStatus.PARTIAL_CONTENT); + } + + /** + * Assert the response status code is {@code HttpStatus.MULTI_STATUS} (207). + */ + public ExchangeActions isMultiStatus() { + return doMatch(HttpStatus.MULTI_STATUS); + } + + /** + * Assert the response status code is {@code HttpStatus.ALREADY_REPORTED} (208). + */ + public ExchangeActions isAlreadyReported() { + return doMatch(HttpStatus.ALREADY_REPORTED); + } + + /** + * Assert the response status code is {@code HttpStatus.IM_USED} (226). + */ + public ExchangeActions isImUsed() { + return doMatch(HttpStatus.IM_USED); + } + + /** + * Assert the response status code is {@code HttpStatus.MULTIPLE_CHOICES} (300). + */ + public ExchangeActions isMultipleChoices() { + return doMatch(HttpStatus.MULTIPLE_CHOICES); + } + + /** + * Assert the response status code is {@code HttpStatus.MOVED_PERMANENTLY} (301). + */ + public ExchangeActions isMovedPermanently() { + return doMatch(HttpStatus.MOVED_PERMANENTLY); + } + + /** + * Assert the response status code is {@code HttpStatus.FOUND} (302). + */ + public ExchangeActions isFound() { + return doMatch(HttpStatus.FOUND); + } + + /** + * Assert the response status code is {@code HttpStatus.MOVED_TEMPORARILY} (302). + * @see #isFound() + * @deprecated in favor of {@link #isFound()} + */ + @Deprecated + public ExchangeActions isMovedTemporarily() { + return doMatch(HttpStatus.MOVED_TEMPORARILY); + } + + /** + * Assert the response status code is {@code HttpStatus.SEE_OTHER} (303). + */ + public ExchangeActions isSeeOther() { + return doMatch(HttpStatus.SEE_OTHER); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304). + */ + public ExchangeActions isNotModified() { + return doMatch(HttpStatus.NOT_MODIFIED); + } + + /** + * Assert the response status code is {@code HttpStatus.USE_PROXY} (305). + * @deprecated matching the deprecation of {@code HttpStatus.USE_PROXY} + */ + @Deprecated + public ExchangeActions isUseProxy() { + return doMatch(HttpStatus.USE_PROXY); + } + + /** + * Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307). + */ + public ExchangeActions isTemporaryRedirect() { + return doMatch(HttpStatus.TEMPORARY_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308). + */ + public ExchangeActions isPermanentRedirect() { + return doMatch(HttpStatus.valueOf(308)); + } + + /** + * Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400). + */ + public ExchangeActions isBadRequest() { + return doMatch(HttpStatus.BAD_REQUEST); + } + + /** + * Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401). + */ + public ExchangeActions isUnauthorized() { + return doMatch(HttpStatus.UNAUTHORIZED); + } + + /** + * Assert the response status code is {@code HttpStatus.PAYMENT_REQUIRED} (402). + */ + public ExchangeActions isPaymentRequired() { + return doMatch(HttpStatus.PAYMENT_REQUIRED); + } + + /** + * Assert the response status code is {@code HttpStatus.FORBIDDEN} (403). + */ + public ExchangeActions isForbidden() { + return doMatch(HttpStatus.FORBIDDEN); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_FOUND} (404). + */ + public ExchangeActions isNotFound() { + return doMatch(HttpStatus.NOT_FOUND); + } + + /** + * Assert the response status code is {@code HttpStatus.METHOD_NOT_ALLOWED} (405). + */ + public ExchangeActions isMethodNotAllowed() { + return doMatch(HttpStatus.METHOD_NOT_ALLOWED); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_ACCEPTABLE} (406). + */ + public ExchangeActions isNotAcceptable() { + return doMatch(HttpStatus.NOT_ACCEPTABLE); + } + + /** + * Assert the response status code is {@code HttpStatus.PROXY_AUTHENTICATION_REQUIRED} (407). + */ + public ExchangeActions isProxyAuthenticationRequired() { + return doMatch(HttpStatus.PROXY_AUTHENTICATION_REQUIRED); + } + + /** + * Assert the response status code is {@code HttpStatus.REQUEST_TIMEOUT} (408). + */ + public ExchangeActions isRequestTimeout() { + return doMatch(HttpStatus.REQUEST_TIMEOUT); + } + + /** + * Assert the response status code is {@code HttpStatus.CONFLICT} (409). + */ + public ExchangeActions isConflict() { + return doMatch(HttpStatus.CONFLICT); + } + + /** + * Assert the response status code is {@code HttpStatus.GONE} (410). + */ + public ExchangeActions isGone() { + return doMatch(HttpStatus.GONE); + } + + /** + * Assert the response status code is {@code HttpStatus.LENGTH_REQUIRED} (411). + */ + public ExchangeActions isLengthRequired() { + return doMatch(HttpStatus.LENGTH_REQUIRED); + } + + /** + * Assert the response status code is {@code HttpStatus.PRECONDITION_FAILED} (412). + */ + public ExchangeActions isPreconditionFailed() { + return doMatch(HttpStatus.PRECONDITION_FAILED); + } + + /** + * Assert the response status code is {@code HttpStatus.PAYLOAD_TOO_LARGE} (413). + * @since 4.1 + */ + public ExchangeActions isPayloadTooLarge() { + return doMatch(HttpStatus.PAYLOAD_TOO_LARGE); + } + + /** + * Assert the response status code is {@code HttpStatus.REQUEST_ENTITY_TOO_LARGE} (413). + * @deprecated matching the deprecation of {@code HttpStatus.REQUEST_ENTITY_TOO_LARGE} + * @see #isPayloadTooLarge() + */ + @Deprecated + public ExchangeActions isRequestEntityTooLarge() { + return doMatch(HttpStatus.REQUEST_ENTITY_TOO_LARGE); + } + + /** + * Assert the response status code is {@code HttpStatus.REQUEST_URI_TOO_LONG} (414). + * @since 4.1 + */ + public ExchangeActions isUriTooLong() { + return doMatch(HttpStatus.URI_TOO_LONG); + } + + /** + * Assert the response status code is {@code HttpStatus.REQUEST_URI_TOO_LONG} (414). + * @deprecated matching the deprecation of {@code HttpStatus.REQUEST_URI_TOO_LONG} + * @see #isUriTooLong() + */ + @Deprecated + public ExchangeActions isRequestUriTooLong() { + return doMatch(HttpStatus.REQUEST_URI_TOO_LONG); + } + + /** + * Assert the response status code is {@code HttpStatus.UNSUPPORTED_MEDIA_TYPE} (415). + */ + public ExchangeActions isUnsupportedMediaType() { + return doMatch(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + } + + /** + * Assert the response status code is {@code HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE} (416). + */ + public ExchangeActions isRequestedRangeNotSatisfiable() { + return doMatch(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + } + + /** + * Assert the response status code is {@code HttpStatus.EXPECTATION_FAILED} (417). + */ + public ExchangeActions isExpectationFailed() { + return doMatch(HttpStatus.EXPECTATION_FAILED); + } + + /** + * Assert the response status code is {@code HttpStatus.I_AM_A_TEAPOT} (418). + */ + public ExchangeActions isIAmATeapot() { + return doMatch(HttpStatus.valueOf(418)); + } + + /** + * Assert the response status code is {@code HttpStatus.INSUFFICIENT_SPACE_ON_RESOURCE} (419). + * @deprecated matching the deprecation of {@code HttpStatus.INSUFFICIENT_SPACE_ON_RESOURCE} + */ + @Deprecated + public ExchangeActions isInsufficientSpaceOnResource() { + return doMatch(HttpStatus.INSUFFICIENT_SPACE_ON_RESOURCE); + } + + /** + * Assert the response status code is {@code HttpStatus.METHOD_FAILURE} (420). + * @deprecated matching the deprecation of {@code HttpStatus.METHOD_FAILURE} + */ + @Deprecated + public ExchangeActions isMethodFailure() { + return doMatch(HttpStatus.METHOD_FAILURE); + } + + /** + * Assert the response status code is {@code HttpStatus.DESTINATION_LOCKED} (421). + * @deprecated matching the deprecation of {@code HttpStatus.DESTINATION_LOCKED} + */ + @Deprecated + public ExchangeActions isDestinationLocked() { + return doMatch(HttpStatus.DESTINATION_LOCKED); + } + + /** + * Assert the response status code is {@code HttpStatus.UNPROCESSABLE_ENTITY} (422). + */ + public ExchangeActions isUnprocessableEntity() { + return doMatch(HttpStatus.UNPROCESSABLE_ENTITY); + } + + /** + * Assert the response status code is {@code HttpStatus.LOCKED} (423). + */ + public ExchangeActions isLocked() { + return doMatch(HttpStatus.LOCKED); + } + + /** + * Assert the response status code is {@code HttpStatus.FAILED_DEPENDENCY} (424). + */ + public ExchangeActions isFailedDependency() { + return doMatch(HttpStatus.FAILED_DEPENDENCY); + } + + /** + * Assert the response status code is {@code HttpStatus.UPGRADE_REQUIRED} (426). + */ + public ExchangeActions isUpgradeRequired() { + return doMatch(HttpStatus.UPGRADE_REQUIRED); + } + + /** + * Assert the response status code is {@code HttpStatus.PRECONDITION_REQUIRED} (428). + */ + public ExchangeActions isPreconditionRequired() { + return doMatch(HttpStatus.valueOf(428)); + } + + /** + * Assert the response status code is {@code HttpStatus.TOO_MANY_REQUESTS} (429). + */ + public ExchangeActions isTooManyRequests() { + return doMatch(HttpStatus.valueOf(429)); + } + + /** + * Assert the response status code is {@code HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE} (431). + */ + public ExchangeActions isRequestHeaderFieldsTooLarge() { + return doMatch(HttpStatus.valueOf(431)); + } + + /** + * Assert the response status code is {@code HttpStatus.UNAVAILABLE_FOR_LEGAL_REASONS} (451). + * @since 4.3 + */ + public ExchangeActions isUnavailableForLegalReasons() { + return doMatch(HttpStatus.valueOf(451)); + } + + /** + * Assert the response status code is {@code HttpStatus.INTERNAL_SERVER_ERROR} (500). + */ + public ExchangeActions isInternalServerError() { + return doMatch(HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_IMPLEMENTED} (501). + */ + public ExchangeActions isNotImplemented() { + return doMatch(HttpStatus.NOT_IMPLEMENTED); + } + + /** + * Assert the response status code is {@code HttpStatus.BAD_GATEWAY} (502). + */ + public ExchangeActions isBadGateway() { + return doMatch(HttpStatus.BAD_GATEWAY); + } + + /** + * Assert the response status code is {@code HttpStatus.SERVICE_UNAVAILABLE} (503). + */ + public ExchangeActions isServiceUnavailable() { + return doMatch(HttpStatus.SERVICE_UNAVAILABLE); + } + + /** + * Assert the response status code is {@code HttpStatus.GATEWAY_TIMEOUT} (504). + */ + public ExchangeActions isGatewayTimeout() { + return doMatch(HttpStatus.GATEWAY_TIMEOUT); + } + + /** + * Assert the response status code is {@code HttpStatus.HTTP_VERSION_NOT_SUPPORTED} (505). + */ + public ExchangeActions isHttpVersionNotSupported() { + return doMatch(HttpStatus.HTTP_VERSION_NOT_SUPPORTED); + } + + /** + * Assert the response status code is {@code HttpStatus.VARIANT_ALSO_NEGOTIATES} (506). + */ + public ExchangeActions isVariantAlsoNegotiates() { + return doMatch(HttpStatus.VARIANT_ALSO_NEGOTIATES); + } + + /** + * Assert the response status code is {@code HttpStatus.INSUFFICIENT_STORAGE} (507). + */ + public ExchangeActions isInsufficientStorage() { + return doMatch(HttpStatus.INSUFFICIENT_STORAGE); + } + + /** + * Assert the response status code is {@code HttpStatus.LOOP_DETECTED} (508). + */ + public ExchangeActions isLoopDetected() { + return doMatch(HttpStatus.LOOP_DETECTED); + } + + /** + * Assert the response status code is {@code HttpStatus.BANDWIDTH_LIMIT_EXCEEDED} (509). + */ + public ExchangeActions isBandwidthLimitExceeded() { + return doMatch(HttpStatus.valueOf(509)); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_EXTENDED} (510). + */ + public ExchangeActions isNotExtended() { + return doMatch(HttpStatus.NOT_EXTENDED); + } + + /** + * Assert the response status code is {@code HttpStatus.NETWORK_AUTHENTICATION_REQUIRED} (511). + */ + public ExchangeActions isNetworkAuthenticationRequired() { + return doMatch(HttpStatus.valueOf(511)); + } + + private ExchangeActions doMatch(final HttpStatus status) { + assertEquals("Status", status, this.httpStatus); + return this.exchangeActions; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/StringMultiValueMapEntryAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StringMultiValueMapEntryAssertions.java new file mode 100644 index 00000000000..31309921ebf --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/StringMultiValueMapEntryAssertions.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.util.Arrays; +import java.util.regex.Pattern; + +import org.springframework.util.MultiValueMap; + +import static org.springframework.test.util.AssertionErrors.assertTrue; + +/** + * Extension of {@link MultiValueMapEntryAssertions} for String values. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class StringMultiValueMapEntryAssertions extends MultiValueMapEntryAssertions { + + + public StringMultiValueMapEntryAssertions(ExchangeActions actions, String name, + MultiValueMap map, String errorMessagePrefix) { + + super(actions, name, map, errorMessagePrefix); + } + + + /** + * The specified value {@link String#contains contains} the given sub-strings. + * @param values the values to match + */ + public ExchangeActions valueContains(CharSequence... values) { + String actual = getValue(0); + Arrays.stream(values).forEach(value -> { + String message = getErrorMessagePrefix() + " does not contain " + value; + assertTrue(message, actual.contains(value)); + }); + return getExchangeActions(); + } + + /** + * The specified value does not {@link String#contains contain} the given sub-strings. + * @param values the values to match + */ + public ExchangeActions valueDoesNotContain(CharSequence... values) { + String actual = getValue(0); + Arrays.stream(values).forEach(value -> { + String message = getErrorMessagePrefix() + " contains " + value + " but shouldn't"; + assertTrue(message, !actual.contains(value)); + }); + return getExchangeActions(); + } + + /** + * The specified value matches the given regex pattern value. + * @param pattern the values to be compiled with {@link Pattern} + */ + public ExchangeActions valueMatches(String pattern) { + String actual = getValue(0); + boolean match = Pattern.compile(pattern).matcher(actual).matches(); + String message = getErrorMessagePrefix() + " with value " + actual + " does not match " + pattern; + assertTrue(message, match); + return getExchangeActions(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java new file mode 100644 index 00000000000..fff068c3a19 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -0,0 +1,477 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.reactivestreams.Publisher; + +import org.springframework.context.ApplicationContext; +import org.springframework.format.FormatterRegistry; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.codec.HttpMessageReader; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.Validator; +import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; +import org.springframework.web.reactive.config.CorsRegistry; +import org.springframework.web.reactive.config.PathMatchConfigurer; +import org.springframework.web.reactive.config.ViewResolverRegistry; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.server.adapter.HttpWebHandlerAdapter; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriBuilderFactory; + +/** + * Main entry point for testing WebFlux server endpoints with an API similar to + * that of {@link WebClient}, and actually delegating to a {@code WebClient} + * instance, but with a focus on testing. + * + *

The {@code WebTestClient} has 3 setup options without a running server: + *

    + *
  • {@link #bindToController} + *
  • {@link #bindToApplicationContext} + *
  • {@link #bindToRouterFunction} + *
+ *

and 1 option for actual requests on a socket: + *

    + *
  • {@link #bindToServer()} + *
+ * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public interface WebTestClient { + + /** + * Prepare an HTTP GET request. + * @return a spec for specifying the target URL + */ + UriSpec get(); + + /** + * Prepare an HTTP HEAD request. + * @return a spec for specifying the target URL + */ + UriSpec head(); + + /** + * Prepare an HTTP POST request. + * @return a spec for specifying the target URL + */ + UriSpec post(); + + /** + * Prepare an HTTP PUT request. + * @return a spec for specifying the target URL + */ + UriSpec put(); + + /** + * Prepare an HTTP PATCH request. + * @return a spec for specifying the target URL + */ + UriSpec patch(); + + /** + * Prepare an HTTP DELETE request. + * @return a spec for specifying the target URL + */ + UriSpec delete(); + + /** + * Prepare an HTTP OPTIONS request. + * @return a spec for specifying the target URL + */ + UriSpec options(); + + + /** + * Filter the client with the given {@code ExchangeFilterFunction}. + * @param filterFunction the filter to apply to this client + * @return the filtered client + * @see ExchangeFilterFunction#apply(ExchangeFunction) + */ + WebTestClient filter(ExchangeFilterFunction filterFunction); + + + // Static, factory methods + + /** + * Integration testing without a server, targeting specific annotated, + * WebFlux controllers. The default configuration is the same as for + * {@link org.springframework.web.reactive.config.EnableWebFlux @EnableWebFlux} + * but can also be further customized through the returned spec. + * @param controllers the controllers to test + * @return spec for controller configuration and test client builder + */ + static ControllerSpec bindToController(Object... controllers) { + return new DefaultControllerSpec(controllers); + } + + /** + * Integration testing without a server, with WebFlux infrastructure detected + * from an {@link ApplicationContext} such as {@code @EnableWebFlux} + * Java config and annotated controller Spring beans. + * @param applicationContext the context + * @return the {@link WebTestClient} builder + * @see org.springframework.web.reactive.config.EnableWebFlux + */ + static WebClientSpec bindToApplicationContext(ApplicationContext applicationContext) { + HttpHandler httpHandler = WebHttpHandlerBuilder.applicationContext(applicationContext).build(); + return new DefaultWebClientSpec(httpHandler); + } + + /** + * Integration testing without a server, targeting WebFlux functional endpoints. + * @param routerFunction the RouterFunction to test + * @return the {@link WebTestClient} builder + */ + static WebClientSpec bindToRouterFunction(RouterFunction routerFunction) { + HttpWebHandlerAdapter httpHandler = RouterFunctions.toHttpHandler(routerFunction); + return new DefaultWebClientSpec(httpHandler); + } + + /** + * Complete end-to-end integration tests with actual requests to a running server. + * @return the {@link WebTestClient} builder + */ + static WebClientSpec bindToServer() { + return new DefaultWebClientSpec(); + } + + + /** + * Specification for customizing controller configuration equivalent to, and + * internally delegating to, a {@link WebFluxConfigurer}. + */ + interface ControllerSpec { + + /** + * Customize content type resolution. + * @see WebFluxConfigurer#configureContentTypeResolver + */ + ControllerSpec contentTypeResolver(Consumer consumer); + + /** + * Configure CORS support. + * @see WebFluxConfigurer#addCorsMappings + */ + ControllerSpec corsMappings(Consumer consumer); + + /** + * Configure path matching options. + * @see WebFluxConfigurer#configurePathMatching + */ + ControllerSpec pathMatching(Consumer consumer); + + /** + * Modify or extend the list of built-in message readers. + * @see WebFluxConfigurer#configureMessageReaders + */ + ControllerSpec messageReaders(Consumer>> readers); + + /** + * Modify or extend the list of built-in message writers. + * @see WebFluxConfigurer#configureMessageWriters + */ + ControllerSpec messageWriters(Consumer>> writers); + + /** + * Register formatters and converters to use for type conversion. + * @see WebFluxConfigurer#addFormatters + */ + ControllerSpec formatters(Consumer consumer); + + /** + * Configure a global Validator. + * @see WebFluxConfigurer#getValidator() + */ + ControllerSpec validator(Validator validator); + + /** + * Configure view resolution. + * @see WebFluxConfigurer#configureViewResolvers + */ + ControllerSpec viewResolvers(Consumer consumer); + + /** + * Proceed to configure the {@link WebClient} to test with. + */ + WebClientSpec webClientSpec(); + + /** + * Shortcut to build the {@link WebTestClient}. + */ + WebTestClient build(); + + } + + /** + * Steps for customizing the {@link WebClient} used to test with + * internally delegating to a {@link WebClient.Builder}. + */ + interface WebClientSpec { + + /** + * Configure a base URI as described in + * {@link org.springframework.web.reactive.function.client.WebClient#create(String) + * WebClient.create(String)}. + * @see #defaultUriVariables(Map) + * @see #uriBuilderFactory(UriBuilderFactory) + */ + WebClientSpec baseUrl(String baseUrl); + + /** + * Configure default URI variable values that will be used when expanding + * URI templates using a {@link Map}. + * @param defaultUriVariables the default values to use + * @see #baseUrl(String) + * @see #uriBuilderFactory(UriBuilderFactory) + */ + WebClientSpec defaultUriVariables(Map defaultUriVariables); + + /** + * Provide a pre-configured {@link UriBuilderFactory} instance. This is + * an alternative to and effectively overrides the following: + *
    + *
  • {@link #baseUrl(String)} + *
  • {@link #defaultUriVariables(Map)}. + *
+ * @param uriBuilderFactory the URI builder factory to use + * @see #baseUrl(String) + * @see #defaultUriVariables(Map) + */ + WebClientSpec uriBuilderFactory(UriBuilderFactory uriBuilderFactory); + + /** + * Add the given header to all requests that haven't added it. + * @param headerName the header name + * @param headerValues the header values + */ + WebClientSpec defaultHeader(String headerName, String... headerValues); + + /** + * Add the given header to all requests that haven't added it. + * @param cookieName the cookie name + * @param cookieValues the cookie values + */ + WebClientSpec defaultCookie(String cookieName, String... cookieValues); + + /** + * Configure the {@link ExchangeStrategies} to use. + *

By default {@link ExchangeStrategies#withDefaults()} is used. + * @param strategies the strategies to use + */ + WebClientSpec exchangeStrategies(ExchangeStrategies strategies); + + /** + * Proceed to building the {@link WebTestClient}. + */ + Builder builder(); + + /** + * Shortcut to build the {@link WebTestClient}. + */ + WebTestClient build(); + + } + + /** + * Build steps to create a {@link WebTestClient}. + */ + interface Builder { + + /** + * Max amount of time to wait for responses. + *

By default 5 seconds. + * @param timeout the response timeout value + */ + Builder responseTimeout(Duration timeout); + + + /** + * Build the {@link WebTestClient} instance. + */ + WebTestClient build(); + + } + + + /** + * Contract for specifying the URI for a request. + */ + interface UriSpec { + + /** + * Specify the URI using an absolute, fully constructed {@link URI}. + */ + HeaderSpec uri(URI uri); + + /** + * Specify the URI for the request using a URI template and URI variables. + * If a {@link UriBuilderFactory} was configured for the client (e.g. + * with a base URI) it will be used to expand the URI template. + */ + HeaderSpec uri(String uri, Object... uriVariables); + + /** + * Specify the URI for the request using a URI template and URI variables. + * If a {@link UriBuilderFactory} was configured for the client (e.g. + * with a base URI) it will be used to expand the URI template. + */ + HeaderSpec uri(String uri, Map uriVariables); + + /** + * Build the URI for the request with a {@link UriBuilder} obtained + * through the {@link UriBuilderFactory} configured for this client. + */ + HeaderSpec uri(Function uriFunction); + + } + + /** + * Contract for specifying request headers leading up to the exchange. + */ + interface HeaderSpec { + + /** + * Set the list of acceptable {@linkplain MediaType media types}, as + * specified by the {@code Accept} header. + * @param acceptableMediaTypes the acceptable media types + * @return this builder + */ + HeaderSpec accept(MediaType... acceptableMediaTypes); + + /** + * Set the list of acceptable {@linkplain Charset charsets}, as specified + * by the {@code Accept-Charset} header. + * @param acceptableCharsets the acceptable charsets + * @return this builder + */ + HeaderSpec acceptCharset(Charset... acceptableCharsets); + + /** + * Set the length of the body in bytes, as specified by the + * {@code Content-Length} header. + * @param contentLength the content length + * @return this builder + * @see HttpHeaders#setContentLength(long) + */ + HeaderSpec contentLength(long contentLength); + + /** + * Set the {@linkplain MediaType media type} of the body, as specified + * by the {@code Content-Type} header. + * @param contentType the content type + * @return this builder + * @see HttpHeaders#setContentType(MediaType) + */ + HeaderSpec contentType(MediaType contentType); + + /** + * Add a cookie with the given name and value. + * @param name the cookie name + * @param value the cookie value + * @return this builder + */ + HeaderSpec cookie(String name, String value); + + /** + * Copy the given cookies into the entity's cookies map. + * + * @param cookies the existing cookies to copy from + * @return this builder + */ + HeaderSpec cookies(MultiValueMap cookies); + + /** + * Set the value of the {@code If-Modified-Since} header. + *

The date should be specified as the number of milliseconds since + * January 1, 1970 GMT. + * @param ifModifiedSince the new value of the header + * @return this builder + */ + HeaderSpec ifModifiedSince(ZonedDateTime ifModifiedSince); + + /** + * Set the values of the {@code If-None-Match} header. + * @param ifNoneMatches the new value of the header + * @return this builder + */ + HeaderSpec ifNoneMatch(String... ifNoneMatches); + + /** + * Add the given, single header value under the given name. + * @param headerName the header name + * @param headerValues the header value(s) + * @return this builder + */ + HeaderSpec header(String headerName, String... headerValues); + + /** + * Copy the given headers into the entity's headers map. + * @param headers the existing headers to copy from + * @return this builder + */ + HeaderSpec headers(HttpHeaders headers); + + /** + * Perform the request without a request body. + * @return options for asserting the response with + */ + ExchangeActions exchange(); + + /** + * Set the body of the request to the given {@code BodyInserter} and + * perform the request. + * @param inserter the {@code BodyInserter} that writes to the request + * @param the type contained in the body + * @return options for asserting the response with + */ + ExchangeActions exchange(BodyInserter inserter); + + /** + * Set the body of the request to the given {@code Publisher} and + * perform the request. + * @param publisher the {@code Publisher} to write to the request + * @param elementClass the class of elements contained in the publisher + * @param the type of the elements contained in the publisher + * @param the type of the {@code Publisher} + * @return options for asserting the response with + */ + > ExchangeActions exchange(S publisher, Class elementClass); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java new file mode 100644 index 00000000000..d5497750f8f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java @@ -0,0 +1,102 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.client.reactive.ClientHttpResponse; + +/** + * Decorates a {@link ClientHttpConnector} in order to capture executed requests + * and responses and notify one or more registered listeners. This is helpful + * for access to the actual {@link ClientHttpRequest} sent and the + * {@link ClientHttpResponse} returned by the server. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class WiretapConnector implements ClientHttpConnector { + + private final ClientHttpConnector delegate; + + private final List> listeners; + + + public WiretapConnector(ClientHttpConnector delegate) { + this.delegate = delegate; + this.listeners = new CopyOnWriteArrayList<>(); + } + + + /** + * Register a listener to consume exchanged requests and responses. + */ + public void addListener(Consumer consumer) { + this.listeners.add(consumer); + } + + + @Override + public Mono connect(HttpMethod method, URI uri, + Function> requestCallback) { + + AtomicReference requestRef = new AtomicReference<>(); + + return this.delegate + .connect(method, uri, request -> { + requestRef.set(request); + return requestCallback.apply(request); + }) + .doOnNext(response -> { + Info info = new Info(requestRef.get(), response); + this.listeners.forEach(consumer -> consumer.accept(info)); + }); + } + + + public static class Info { + + private final ClientHttpRequest request; + + private final ClientHttpResponse response; + + + public Info(ClientHttpRequest request, ClientHttpResponse response) { + this.request = request; + this.response = response; + } + + + public ClientHttpRequest getRequest() { + return this.request; + } + + public ClientHttpResponse getResponse() { + return this.response; + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/package-info.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/package-info.java new file mode 100644 index 00000000000..90489f3f00d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/package-info.java @@ -0,0 +1,5 @@ +/** + * Support for testing Spring WebFlux server endpoints via + * {@link org.springframework.test.web.reactive.server.WebTestClient}. + */ +package org.springframework.test.web.reactive.server; diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ErrorTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ErrorTests.java new file mode 100644 index 00000000000..5a2fdf64573 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ErrorTests.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests with error status codes or error conditions. + * + * @author Rossen Stoyanchev + */ +@SuppressWarnings("unused") +public class ErrorTests { + + private WebTestClient client; + + + @Before + public void setUp() throws Exception { + this.client = WebTestClient.bindToController(new TestController()).build(); + } + + + @Test + public void notFound() throws Exception { + this.client.get().uri("/invalid") + .exchange() + .assertStatus().isNotFound(); + } + + @Test + public void serverException() throws Exception { + this.client.get().uri("/server-error") + .exchange() + .assertStatus().isInternalServerError(); + } + + + @RestController + static class TestController { + + @GetMapping("/server-error") + void handleAndThrowException() { + throw new IllegalStateException("server error"); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/HeaderTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/HeaderTests.java new file mode 100644 index 00000000000..8f47199b941 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/HeaderTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests with custom headers. + * + * @author Rossen Stoyanchev + */ +@SuppressWarnings("unused") +public class HeaderTests { + + private WebTestClient client; + + + @Before + public void setUp() throws Exception { + this.client = WebTestClient.bindToController(new TestController()).build(); + } + + + @Test + public void customHeader() throws Exception { + this.client.get().uri("/header").header("h1", "ping") + .exchange() + .assertStatus().isOk() + .assertHeader("h1").isEqualTo("ping-pong"); + } + + + @RestController + static class TestController { + + @GetMapping("header") + ResponseEntity handleHeader(@RequestHeader("h1") String myHeader) { + String value = myHeader + "-pong"; + return ResponseEntity.ok().header("h1", value).build(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ResponseEntityTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ResponseEntityTests.java new file mode 100644 index 00000000000..ff8327af499 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/ResponseEntityTests.java @@ -0,0 +1,159 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples; + +import java.net.URI; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.hamcrest.CoreMatchers.endsWith; +import static org.junit.Assert.assertThat; +import static org.springframework.http.MediaType.TEXT_EVENT_STREAM; + +/** + * Annotated controllers accepting and returning typed Objects. + * + * @author Rossen Stoyanchev + */ +@SuppressWarnings("unused") +public class ResponseEntityTests { + + private WebTestClient client; + + + @Before + public void setUp() throws Exception { + this.client = WebTestClient.bindToController(new PersonController()).build(); + } + + + @Test + public void entity() throws Exception { + this.client.get().uri("/persons/John") + .exchange() + .assertStatus().isOk() + .assertHeaders().contentType(MediaType.APPLICATION_JSON_UTF8) + .assertEntity(Person.class).isEqualTo(new Person("John")); + } + + @Test + public void entityCollection() throws Exception { + this.client.get().uri("/persons") + .exchange() + .assertStatus().isOk() + .assertHeaders().contentType(MediaType.APPLICATION_JSON_UTF8) + .assertEntity(Person.class).collection() + .hasSize(3) + .contains(new Person("Jane"), new Person("Jason"), new Person("John")); + } + + @Test + public void entityStream() throws Exception { + this.client.get().uri("/persons").accept(TEXT_EVENT_STREAM) + .exchange() + .assertStatus().isOk() + .assertHeaders().contentType(TEXT_EVENT_STREAM) + .assertEntity(Person.class).stepVerifier() + .expectNext(new Person("N0"), new Person("N1"), new Person("N2")) + .expectNextCount(4) + .consumeNextWith(person -> assertThat(person.getName(), endsWith("7"))) + .thenCancel() + .verify(); + } + + @Test + public void saveEntity() throws Exception { + this.client.post().uri("/persons") + .exchange(Mono.just(new Person("John")), Person.class) + .assertStatus().isCreated() + .assertHeader("location").isEqualTo("/persons/John") + .assertNoContent(); + } + + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping("/{name}") + Person getPerson(@PathVariable String name) { + return new Person(name); + } + + @GetMapping + Flux getPersons() { + return Flux.just(new Person("Jane"), new Person("Jason"), new Person("John")); + } + + @GetMapping(produces = "text/event-stream") + Flux getPersonStream() { + return Flux.intervalMillis(100).onBackpressureBuffer(10).map(index -> new Person("N" + index)); + } + + @PostMapping + ResponseEntity savePerson(@RequestBody Person person) { + return ResponseEntity.created(URI.create("/persons/" + person.getName())).build(); + } + } + + static class Person { + + private final String name; + + @JsonCreator + public Person(@JsonProperty("name") String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + Person person = (Person) other; + return getName().equals(person.getName()); + } + + @Override + public int hashCode() { + return getName().hashCode(); + } + + @Override + public String toString() { + return "Person[name='" + name + "']"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ApplicationContextTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ApplicationContextTests.java new file mode 100644 index 00000000000..9cfefa29117 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ApplicationContextTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples.bind; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.EnableWebFlux; + +/** + * Binding to server infrastructure declared in a Spring ApplicationContext. + * + * @author Rossen Stoyanchev + */ +@SuppressWarnings("unused") +public class ApplicationContextTests { + + private WebTestClient client; + + + @Before + public void setUp() throws Exception { + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(WebConfig.class); + context.refresh(); + + this.client = WebTestClient.bindToApplicationContext(context).build(); + } + + @Test + public void test() throws Exception { + this.client.get().uri("/test") + .exchange() + .assertStatus().isOk() + .assertEntity(String .class).isEqualTo("It works!"); + } + + + @Configuration + @EnableWebFlux + static class WebConfig { + + @Bean + public TestController controller() { + return new TestController(); + } + + } + + @RestController + static class TestController { + + @GetMapping("/test") + public String handle() { + return "It works!"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ControllerTests.java new file mode 100644 index 00000000000..542e6aacb6a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/ControllerTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples.bind; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Bind to annotated controllers. + * + * @author Rossen Stoyanchev + */ +@SuppressWarnings("unused") +public class ControllerTests { + + private WebTestClient client; + + + @Before + public void setUp() throws Exception { + this.client = WebTestClient.bindToController(new TestController()).build(); + } + + + @Test + public void test() throws Exception { + this.client.get().uri("/test") + .exchange() + .assertStatus().isOk() + .assertEntity(String .class).isEqualTo("It works!"); + } + + + @RestController + static class TestController { + + @GetMapping("/test") + public String handle() { + return "It works!"; + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/HttpServerTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/HttpServerTests.java new file mode 100644 index 00000000000..aede9ed6512 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/HttpServerTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples.bind; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.bootstrap.ReactorHttpServer; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +/** + * Bind to a running server, making actual requests over a socket. + * + * @author Rossen Stoyanchev + */ +public class HttpServerTests { + + private ReactorHttpServer server; + + private WebTestClient client; + + + @Before + public void setUp() throws Exception { + + HttpHandler httpHandler = RouterFunctions.toHttpHandler( + route(GET("/test"), request -> + ServerResponse.ok().body(Mono.just("It works!"), String.class))); + + this.server = new ReactorHttpServer(); + this.server.setHandler(httpHandler); + this.server.afterPropertiesSet(); + this.server.start(); + + this.client = WebTestClient.bindToServer() + .baseUrl("http://localhost:" + this.server.getPort()) + .build(); + } + + @After + public void tearDown() throws Exception { + this.server.stop(); + } + + + @Test + public void test() throws Exception { + this.client.get().uri("/test") + .exchange() + .assertStatus().isOk() + .assertEntity(String .class).isEqualTo("It works!"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/RouterFunctionTests.java new file mode 100644 index 00000000000..49c1dd6cf8d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/samples/bind/RouterFunctionTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server.samples.bind; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +/** + * Bind to a {@link RouterFunction} and functional endpoints. + * + * @author Rossen Stoyanchev + */ +public class RouterFunctionTests { + + private WebTestClient testClient; + + + @Before + public void setUp() throws Exception { + + RouterFunction route = route(GET("/test"), request -> + ServerResponse.ok().body(Mono.just("It works!"), String.class)); + + this.testClient = WebTestClient.bindToRouterFunction(route).build(); + } + + @Test + public void test() throws Exception { + this.testClient.get().uri("/test") + .exchange() + .assertStatus().isOk() + .assertEntity(String .class).isEqualTo("It works!"); + } + +}