From 7445f542f4c0a2bf48617fdeb131f1f6aad04130 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Tue, 4 Nov 2025 16:33:50 +0000 Subject: [PATCH] AssertJ support for WebTestClient Closes gh-35737 --- .../reactive/server/DefaultWebTestClient.java | 3 +- .../web/reactive/server/ExchangeResult.java | 17 +- .../reactive/server/JsonEncoderDecoder.java | 45 +++ .../web/reactive/server/WiretapConnector.java | 9 +- .../assertj/DefaultWebTestClientResponse.java | 51 +++ .../assertj/ResponseCookieMapAssert.java | 163 +++++++++ .../server/assertj/WebTestClientResponse.java | 64 ++++ .../assertj/WebTestClientResponseAssert.java | 337 ++++++++++++++++++ .../reactive/server/assertj/package-info.java | 7 + .../server/WiretapConnectorTests.java | 2 +- .../assertj/ResponseCookieMapAssertTests.java | 176 +++++++++ .../assertj/WebTestClientResponseTests.java | 126 +++++++ 12 files changed, 995 insertions(+), 5 deletions(-) create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/DefaultWebTestClientResponse.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponse.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseAssert.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/package-info.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssertTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseTests.java 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 index 37ad8458970..5109efe2ef5 100644 --- 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 @@ -111,9 +111,10 @@ class DefaultWebTestClient implements WebTestClient { Consumer> entityResultConsumer, @Nullable Duration responseTimeout, DefaultWebTestClientBuilder clientBuilder) { - this.wiretapConnector = new WiretapConnector(connector); this.jsonEncoderDecoder = JsonEncoderDecoder.from( exchangeStrategies.messageWriters(), exchangeStrategies.messageReaders()); + + this.wiretapConnector = new WiretapConnector(connector, this.jsonEncoderDecoder); this.exchangeFunction = exchangeFactory.apply(this.wiretapConnector); this.uriBuilderFactory = uriBuilderFactory; this.defaultHeaders = headers; diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java index 90be0bee0c9..48b82e9fab6 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java @@ -36,6 +36,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseCookie; import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.test.json.JsonConverterDelegate; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; @@ -78,6 +79,8 @@ public class ExchangeResult { private final @Nullable Object mockServerResult; + private final @Nullable JsonConverterDelegate converterDelegate; + /** Ensure single logging, for example, for expectAll. */ private boolean diagnosticsLogged; @@ -93,10 +96,11 @@ public class ExchangeResult { * @param timeout how long to wait for content to materialize * @param uriTemplate the URI template used to set up the request, if any * @param serverResult the result of a mock server exchange if applicable. + * @param converterDelegate for JSON decoding in AssertJ support */ ExchangeResult(ClientHttpRequest request, ClientHttpResponse response, Mono requestBody, Mono responseBody, Duration timeout, @Nullable String uriTemplate, - @Nullable Object serverResult) { + @Nullable Object serverResult, @Nullable JsonConverterDelegate converterDelegate) { Assert.notNull(request, "ClientHttpRequest is required"); Assert.notNull(response, "ClientHttpResponse is required"); @@ -110,6 +114,7 @@ public class ExchangeResult { this.timeout = timeout; this.uriTemplate = uriTemplate; this.mockServerResult = serverResult; + this.converterDelegate = converterDelegate; } /** @@ -123,6 +128,7 @@ public class ExchangeResult { this.timeout = other.timeout; this.uriTemplate = other.uriTemplate; this.mockServerResult = other.mockServerResult; + this.converterDelegate = other.converterDelegate; this.diagnosticsLogged = other.diagnosticsLogged; } @@ -206,6 +212,15 @@ public class ExchangeResult { return this.mockServerResult; } + /** + * Return a {@link JsonConverterDelegate} based on the configured codecs. + * Mainly for internal use from AssertJ support classes. + * @since 7.0 + */ + public @Nullable JsonConverterDelegate getJsonConverterDelegate() { + return this.converterDelegate; + } + /** * Execute the given Runnable, catch any {@link AssertionError}, log details * about the request and response at ERROR level under the class log diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java index 8737685152f..64b51fcfd4b 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/JsonEncoderDecoder.java @@ -16,6 +16,7 @@ package org.springframework.test.web.reactive.server; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Map; import java.util.stream.Stream; @@ -25,11 +26,17 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.ResolvableType; import org.springframework.core.codec.Decoder; import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.MediaType; import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.EncoderHttpMessageWriter; import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.test.json.JsonConverterDelegate; +import org.springframework.util.Assert; +import org.springframework.util.MimeTypeUtils; /** * {@link Encoder} and {@link Decoder} that is able to encode and decode @@ -49,6 +56,14 @@ record JsonEncoderDecoder(Encoder encoder, Decoder decoder) { private static final ResolvableType MAP_TYPE = ResolvableType.forClass(Map.class); + /** + * Return a {@link JsonConverterDelegate} that uses the encoder and decoder. + */ + public JsonConverterDelegate createJsonConverterDelegate() { + return new CodecsJsonConverterDelegate(); + } + + /** * Create a {@link JsonEncoderDecoder} instance based on the specified * infrastructure. @@ -107,4 +122,34 @@ record JsonEncoderDecoder(Encoder encoder, Decoder decoder) { .orElse(null); } + + /** + * Implementation that delegates to the contained Encoder and Decoder. + */ + private class CodecsJsonConverterDelegate implements JsonConverterDelegate { + + @SuppressWarnings("unchecked") + @Override + public T read(String content, ResolvableType targetType) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = DefaultDataBufferFactory.sharedInstance.wrap(bytes); + Object value = decoder().decode(buffer, targetType, MediaType.APPLICATION_JSON, null); + Assert.state(value != null, () -> "Could not decode JSON content: " + content); + return (T) value; + } + + @SuppressWarnings("unchecked") + @Override + public T map(Object value, ResolvableType targetType) { + DataBuffer buffer = ((Encoder) encoder()).encodeValue((T) value, + DefaultDataBufferFactory.sharedInstance, targetType, MimeTypeUtils.APPLICATION_JSON, null); + try { + return read(buffer.toString(StandardCharsets.UTF_8), targetType); + } + finally { + DataBufferUtils.release(buffer); + } + } + } + } 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 index 20d620e0a70..bfb88b57bfe 100644 --- 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 @@ -38,6 +38,7 @@ import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.client.reactive.ClientHttpRequestDecorator; import org.springframework.http.client.reactive.ClientHttpResponse; import org.springframework.http.client.reactive.ClientHttpResponseDecorator; +import org.springframework.test.json.JsonConverterDelegate; import org.springframework.util.Assert; /** @@ -53,11 +54,14 @@ class WiretapConnector implements ClientHttpConnector { private final ClientHttpConnector delegate; + private final @Nullable JsonConverterDelegate converterDelegate; + private final Map exchanges = new ConcurrentHashMap<>(); - WiretapConnector(ClientHttpConnector delegate) { + WiretapConnector(ClientHttpConnector delegate, @Nullable JsonEncoderDecoder encoderDecoder) { this.delegate = delegate; + this.converterDelegate = (encoderDecoder != null ? encoderDecoder.createJsonConverterDelegate() : null); } @@ -96,7 +100,8 @@ class WiretapConnector implements ClientHttpConnector { clientInfo.getRequest().getRecorder().getContent(), clientInfo.getResponse().getRecorder().getContent(), timeout, uriTemplate, - clientInfo.getResponse().getMockServerResult()); + clientInfo.getResponse().getMockServerResult(), + this.converterDelegate); } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/DefaultWebTestClientResponse.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/DefaultWebTestClientResponse.java new file mode 100644 index 00000000000..eacf9065238 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/DefaultWebTestClientResponse.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-present 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 + * + * https://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.assertj; + + +import org.springframework.test.web.reactive.server.ExchangeResult; + +/** + * Default implementation of {@link WebTestClientResponse}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +final class DefaultWebTestClientResponse implements WebTestClientResponse { + + private final ExchangeResult exchangeResult; + + + DefaultWebTestClientResponse(ExchangeResult exchangeResult) { + this.exchangeResult = exchangeResult; + } + + + @Override + public ExchangeResult getExchangeResult() { + return this.exchangeResult; + } + + /** + * Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat} instead. + */ + @Override + public WebTestClientResponseAssert assertThat() { + return new WebTestClientResponseAssert(this); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssert.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssert.java new file mode 100644 index 00000000000..71bde92cdd6 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssert.java @@ -0,0 +1,163 @@ +/* + * Copyright 2002-present 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 + * + * https://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.assertj; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.assertj.core.api.AbstractMapAssert; +import org.assertj.core.api.Assertions; + +import org.springframework.http.ResponseCookie; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} that can be applied + * to {@link ResponseCookie cookies}. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public class ResponseCookieMapAssert + extends AbstractMapAssert, String, ResponseCookie> { + + + public ResponseCookieMapAssert(ResponseCookie[] actual) { + super(toMap(actual), ResponseCookieMapAssert.class); + as("Cookies"); + } + + private static Map toMap(ResponseCookie[] cookies) { + Map map = new LinkedHashMap<>(); + for (ResponseCookie cookie : cookies) { + map.putIfAbsent(cookie.getName(), cookie); + } + return map; + } + + + /** + * Verify that the actual cookies contain a cookie with the given {@code name}. + * @param name the name of an expected cookie + * @see #containsKey + */ + public ResponseCookieMapAssert containsCookie(String name) { + return containsKey(name); + } + + /** + * Verify that the actual cookies contain cookies with the given {@code names}. + * @param names the names of expected cookies + * @see #containsKeys + */ + public ResponseCookieMapAssert containsCookies(String... names) { + return containsKeys(names); + } + + /** + * Verify that the actual cookies do not contain a cookie with the given + * {@code name}. + * @param name the name of a cookie that should not be present + * @see #doesNotContainKey + */ + public ResponseCookieMapAssert doesNotContainCookie(String name) { + return doesNotContainKey(name); + } + + /** + * Verify that the actual cookies do not contain any cookies with the given + * {@code names}. + * @param names the names of cookies that should not be present + * @see #doesNotContainKeys + */ + public ResponseCookieMapAssert doesNotContainCookies(String... names) { + return doesNotContainKeys(names); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * that satisfies the given {@code cookieRequirements}. + * @param name the name of an expected cookie + * @param cookieRequirements the requirements for the cookie + */ + public ResponseCookieMapAssert hasCookieSatisfying(String name, Consumer cookieRequirements) { + return hasEntrySatisfying(name, cookieRequirements); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain ResponseCookie#getValue() value} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected value of the cookie + */ + public ResponseCookieMapAssert hasValue(String name, String expected) { + return hasCookieSatisfying(name, cookie -> Assertions.assertThat(cookie.getValue()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain ResponseCookie#getMaxAge() max age} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected max age of the cookie + */ + public ResponseCookieMapAssert hasMaxAge(String name, Duration expected) { + return hasCookieSatisfying(name, cookie -> Assertions.assertThat(cookie.getMaxAge()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain ResponseCookie#getPath() path} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected path of the cookie + */ + public ResponseCookieMapAssert hasPath(String name, String expected) { + return hasCookieSatisfying(name, cookie -> Assertions.assertThat(cookie.getPath()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain ResponseCookie#getDomain() domain} is equal to the given one. + * @param name the name of the cookie + * @param expected the expected domain of the cookie + */ + public ResponseCookieMapAssert hasDomain(String name, String expected) { + return hasCookieSatisfying(name, cookie -> Assertions.assertThat(cookie.getDomain()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain ResponseCookie#isSecure() secure flag} is equal to the give one. + * @param name the name of the cookie + * @param expected whether the cookie is secure + */ + public ResponseCookieMapAssert isSecure(String name, boolean expected) { + return hasCookieSatisfying(name, cookie -> Assertions.assertThat(cookie.isSecure()).isEqualTo(expected)); + } + + /** + * Verify that the actual cookies contain a cookie with the given {@code name} + * whose {@linkplain ResponseCookie#isHttpOnly() http only flag} is equal to the given + * one. + * @param name the name of the cookie + * @param expected whether the cookie is http only + */ + public ResponseCookieMapAssert isHttpOnly(String name, boolean expected) { + return hasCookieSatisfying(name, cookie -> Assertions.assertThat(cookie.isHttpOnly()).isEqualTo(expected)); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponse.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponse.java new file mode 100644 index 00000000000..51b3ee5957a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponse.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-present 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 + * + * https://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.assertj; + +import org.assertj.core.api.AssertProvider; + +import org.springframework.test.web.reactive.server.ExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; + + +/** + * {@link AssertProvider} for {@link WebTestClientResponseAssert} that holds the + * result of an exchange performed through {@link WebTestClient}. Intended for + * further use with AssertJ. For example: + * + *
+ * ResponseSpec spec = webTestClient.get().uri("/greeting").exchange();
+ *
+ * WebTestClientResponse response = WebTestClientResponse.from(spec);
+ * assertThat(response).hasStatusOk();
+ * assertThat(response).contentType().isCompatibleWith(MediaType.APPLICATION_JSON);
+ * assertThat(response).bodyJson().extractingPath("$.message").asString().isEqualTo("Hello World");
+ * 
+ * + * @author Rossen Stoyanchev + * @since 7.0 + */ +public interface WebTestClientResponse extends AssertProvider { + + /** + * Return the underlying {@link ExchangeResult}. + */ + ExchangeResult getExchangeResult(); + + + /** + * Create an instance from a {@link WebTestClient.ResponseSpec}. + */ + static WebTestClientResponse from(WebTestClient.ResponseSpec spec) { + return from(spec.returnResult(byte[].class)); + } + + /** + * Create an instance from an {@link ExchangeResult}. + */ + static WebTestClientResponse from(ExchangeResult result) { + return new DefaultWebTestClientResponse(result); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseAssert.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseAssert.java new file mode 100644 index 00000000000..a80f145f96e --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseAssert.java @@ -0,0 +1,337 @@ +/* + * Copyright 2002-present 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 + * + * https://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.assertj; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.function.Supplier; + +import org.assertj.core.api.AbstractByteArrayAssert; +import org.assertj.core.api.AbstractIntegerAssert; +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ByteArrayAssert; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.test.http.HttpHeadersAssert; +import org.springframework.test.http.MediaTypeAssert; +import org.springframework.test.json.AbstractJsonContentAssert; +import org.springframework.test.json.JsonContent; +import org.springframework.test.json.JsonContentAssert; +import org.springframework.test.web.reactive.server.ExchangeResult; +import org.springframework.util.function.SingletonSupplier; + +/** + * AssertJ {@linkplain org.assertj.core.api.Assert assertions} for the result + * from a {@link org.springframework.test.web.reactive.server.WebTestClient} + * exchange. + * + * @author Rossen Stoyanchev + * @since 7.0 + */ +@SuppressWarnings({"UnusedReturnValue", "unused"}) +public class WebTestClientResponseAssert + extends AbstractObjectAssert { + + private final Supplier contentTypeAssertSupplier; + + private final Supplier headersAssertSupplier; + + private final Supplier> statusAssert; + + + WebTestClientResponseAssert(WebTestClientResponse actual) { + super(actual, WebTestClientResponseAssert.class); + + this.contentTypeAssertSupplier = SingletonSupplier.of(() -> + new MediaTypeAssert(getExchangeResult().getResponseHeaders().getContentType())); + + this.headersAssertSupplier = SingletonSupplier.of(() -> + new HttpHeadersAssert(getExchangeResult().getResponseHeaders())); + + this.statusAssert = SingletonSupplier.of(() -> + Assertions.assertThat(getExchangeResult().getStatus().value()).as("HTTP status code")); + } + + + /** + * Verify that the HTTP status is equal to the specified status code. + * @param status the expected HTTP status code + */ + public WebTestClientResponseAssert hasStatus(int status) { + status().isEqualTo(status); + return this.myself; + } + + /** + * Verify that the HTTP status is equal to the specified + * {@linkplain HttpStatus status}. + * @param status the expected HTTP status code + */ + public WebTestClientResponseAssert hasStatus(HttpStatus status) { + return hasStatus(status.value()); + } + + /** + * Verify that the HTTP status is equal to {@link HttpStatus#OK}. + * @see #hasStatus(HttpStatus) + */ + public WebTestClientResponseAssert hasStatusOk() { + return hasStatus(HttpStatus.OK); + } + + /** + * Verify that the HTTP status code is in the 1xx range. + * @see RFC 2616 + */ + public WebTestClientResponseAssert hasStatus1xxInformational() { + return hasStatusSeries(HttpStatus.Series.INFORMATIONAL); + } + + /** + * Verify that the HTTP status code is in the 2xx range. + * @see RFC 2616 + */ + public WebTestClientResponseAssert hasStatus2xxSuccessful() { + return hasStatusSeries(HttpStatus.Series.SUCCESSFUL); + } + + /** + * Verify that the HTTP status code is in the 3xx range. + * @see RFC 2616 + */ + public WebTestClientResponseAssert hasStatus3xxRedirection() { + return hasStatusSeries(HttpStatus.Series.REDIRECTION); + } + + /** + * Verify that the HTTP status code is in the 4xx range. + * @see RFC 2616 + */ + public WebTestClientResponseAssert hasStatus4xxClientError() { + return hasStatusSeries(HttpStatus.Series.CLIENT_ERROR); + } + + /** + * Verify that the HTTP status code is in the 5xx range. + * @see RFC 2616 + */ + public WebTestClientResponseAssert hasStatus5xxServerError() { + return hasStatusSeries(HttpStatus.Series.SERVER_ERROR); + } + + private WebTestClientResponseAssert hasStatusSeries(HttpStatus.Series series) { + HttpStatusCode status = getExchangeResult().getStatus(); + Assertions.assertThat(HttpStatus.Series.resolve(status.value())).as("HTTP status series").isEqualTo(series); + return this.myself; + } + + private AbstractIntegerAssert status() { + return this.statusAssert.get(); + } + + /** + * Return a new {@linkplain HttpHeadersAssert assertion} object that uses + * {@link HttpHeaders} as the object to test. The returned assertion object + * provides all the regular {@linkplain org.assertj.core.api.AbstractMapAssert + * map assertions}, with headers mapped by header name. + * Examples:

+	 * // Check for the presence of the Accept header:
+	 * assertThat(response).headers().containsHeader(HttpHeaders.ACCEPT);
+	 *
+	 * // Check for the absence of the Content-Length header:
+	 * assertThat(response).headers().doesNotContainsHeader(HttpHeaders.CONTENT_LENGTH);
+	 * 
+ */ + public HttpHeadersAssert headers() { + return this.headersAssertSupplier.get(); + } + + /** + * Verify that the response contains a header with the given {@code name}. + * @param name the name of an expected HTTP header + */ + public WebTestClientResponseAssert containsHeader(String name) { + headers().containsHeader(name); + return this.myself; + } + + /** + * Verify that the response does not contain a header with the given {@code name}. + * @param name the name of an HTTP header that should not be present + */ + public WebTestClientResponseAssert doesNotContainHeader(String name) { + headers().doesNotContainHeader(name); + return this.myself; + } + + /** + * Verify that the response contains a header with the given {@code name} + * and primary {@code value}. + * @param name the name of an expected HTTP header + * @param value the expected value of the header + */ + public WebTestClientResponseAssert hasHeader(String name, String value) { + headers().hasValue(name, value); + return this.myself; + } + + /** + * Return a new {@linkplain MediaTypeAssert assertion} object that uses the + * response's {@linkplain MediaType content type} as the object to test. + */ + public MediaTypeAssert contentType() { + return this.contentTypeAssertSupplier.get(); + } + + /** + * Verify that the response's {@code Content-Type} is equal to the given value. + * @param contentType the expected content type + */ + public WebTestClientResponseAssert hasContentType(MediaType contentType) { + contentType().isEqualTo(contentType); + return this.myself; + } + + /** + * Verify that the response's {@code Content-Type} is equal to the given + * string representation. + * @param contentType the expected content type + */ + public WebTestClientResponseAssert hasContentType(String contentType) { + contentType().isEqualTo(contentType); + return this.myself; + } + + /** + * Verify that the response's {@code Content-Type} is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given value. + * @param contentType the expected compatible content type + */ + public WebTestClientResponseAssert hasContentTypeCompatibleWith(MediaType contentType) { + contentType().isCompatibleWith(contentType); + return this.myself; + } + + /** + * Verify that the response's {@code Content-Type} is + * {@linkplain MediaType#isCompatibleWith(MediaType) compatible} with the + * given string representation. + * @param contentType the expected compatible content type + */ + public WebTestClientResponseAssert hasContentTypeCompatibleWith(String contentType) { + contentType().isCompatibleWith(contentType); + return this.myself; + } + + /** + * Return a new {@linkplain ResponseCookieMapAssert assertion} object that uses the + * response's {@linkplain ResponseCookie cookies} as the object to test. + */ + public ResponseCookieMapAssert cookies() { + return new ResponseCookieMapAssert(getCookies()); + } + + private ResponseCookie[] getCookies() { + return getExchangeResult().getResponseCookies().values().stream() + .flatMap(Collection::stream) + .toArray(ResponseCookie[]:: new); + } + + /** + * Return a new {@linkplain AbstractByteArrayAssert assertion} object that + * uses the response body as the object to test. + * @see #bodyText() + * @see #bodyJson() + */ + public AbstractByteArrayAssert body() { + return new ByteArrayAssert(getExchangeResult().getResponseBodyContent()); + } + + /** + * Return a new {@linkplain AbstractStringAssert assertion} object that uses + * the response body converted to text as the object to test. + *

Examples:


+	 * // Check that the response body is equal to "Hello World":
+	 * assertThat(response).bodyText().isEqualTo("Hello World");
+	 * 
+ */ + public AbstractStringAssert bodyText() { + return Assertions.assertThat(readBody()); + } + + /** + * Verify that the response body is equal to the given value. + */ + public WebTestClientResponseAssert hasBodyTextEqualTo(String bodyText) { + bodyText().isEqualTo(bodyText); + return this.myself; + } + + /** + * Return a new {@linkplain AbstractJsonContentAssert assertion} object that + * uses the response body converted to text as the object to test. Compared + * to {@link #bodyText()}, the assertion object provides dedicated JSON + * support. + *

Examples:


+	 * // Check that the response body is strictly equal to the content of
+	 * // "/com/acme/sample/person-created.json":
+	 * assertThat(response).bodyJson()
+	 *         .isStrictlyEqualToJson("/com/acme/sample/person-created.json");
+	 *
+	 * // Check that the response is strictly equal to the content of the
+	 * // specified file located in the same package as the PersonController:
+	 * assertThat(response).bodyJson().withResourceLoadClass(PersonController.class)
+	 *         .isStrictlyEqualToJson("person-created.json");
+	 * 
+ * The returned assert object also supports JSON path expressions. + *

Examples:


+	 * // Check that the JSON document does not have an "error" element
+	 * assertThat(response).bodyJson().doesNotHavePath("$.error");
+	 *
+	 * // Check that the JSON document as a top level "message" element
+	 * assertThat(response).bodyJson()
+	 *         .extractingPath("$.message").asString().isEqualTo("hello");
+	 * 
+ */ + public AbstractJsonContentAssert bodyJson() { + return new JsonContentAssert(new JsonContent(readBody(), getExchangeResult().getJsonConverterDelegate())); + } + + private String readBody() { + return new String(getExchangeResult().getResponseBodyContent(), getCharset()); + } + + private Charset getCharset() { + ExchangeResult result = getExchangeResult(); + MediaType contentType = result.getResponseHeaders().getContentType(); + Charset charset = (contentType != null ? contentType.getCharset() : null); + return (charset != null ? charset : StandardCharsets.UTF_8); + } + + private ExchangeResult getExchangeResult() { + return this.actual.getExchangeResult(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/package-info.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/package-info.java new file mode 100644 index 00000000000..11c9e3c4b42 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/assertj/package-info.java @@ -0,0 +1,7 @@ +/** + * AssertJ support for WebTestClient. + */ +@NullMarked +package org.springframework.test.web.reactive.server.assertj; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java index 8728ed414ca..4e0e44379f2 100644 --- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java @@ -53,7 +53,7 @@ public class WiretapConnectorTests { ClientRequest clientRequest = ClientRequest.create(HttpMethod.GET, URI.create("/test")) .header(WebTestClient.WEBTESTCLIENT_REQUEST_ID, "1").build(); - WiretapConnector wiretapConnector = new WiretapConnector(connector); + WiretapConnector wiretapConnector = new WiretapConnector(connector, null); ExchangeFunction function = ExchangeFunctions.create(wiretapConnector); function.exchange(clientRequest).block(ofMillis(0)); diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssertTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssertTests.java new file mode 100644 index 00000000000..6df2de21e8d --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/ResponseCookieMapAssertTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-present 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 + * + * https://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.assertj; + +import java.time.Duration; +import java.util.List; + +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.http.ResponseCookie; +import org.springframework.test.web.servlet.assertj.CookieMapAssert; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link CookieMapAssert}. + * + * @author Rossen Stoyanchev + */ +class ResponseCookieMapAssertTests { + + static ResponseCookie[] cookies; + + @BeforeAll + static void setup() { + ResponseCookie framework = ResponseCookie.from("framework", "spring").secure(true).httpOnly(true).build(); + ResponseCookie age = ResponseCookie.from("age", "value").maxAge(1200).build(); + ResponseCookie domain = ResponseCookie.from("domain", "value").domain("spring.io").build(); + ResponseCookie path = ResponseCookie.from("path", "value").path("/spring").build(); + cookies = List.of(framework, age, domain, path).toArray(new ResponseCookie[0]); + } + + @Test + void containsCookieWhenCookieExistsShouldPass() { + assertThat(cookies()).containsCookie("framework"); + } + + @Test + void containsCookieWhenCookieMissingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).containsCookie("missing")); + } + + @Test + void containsCookiesWhenCookiesExistShouldPass() { + assertThat(cookies()).containsCookies("framework", "age"); + } + + @Test + void containsCookiesWhenCookieMissingShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).containsCookies("framework", "missing")); + } + + @Test + void doesNotContainCookieWhenCookieMissingShouldPass() { + assertThat(cookies()).doesNotContainCookie("missing"); + } + + @Test + void doesNotContainCookieWhenCookieExistsShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).doesNotContainCookie("framework")); + } + + @Test + void doesNotContainCookiesWhenCookiesMissingShouldPass() { + assertThat(cookies()).doesNotContainCookies("missing", "missing2"); + } + + @Test + void doesNotContainCookiesWhenAtLeastOneCookieExistShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).doesNotContainCookies("missing", "framework")); + } + + @Test + void hasValueEqualsWhenCookieValueMatchesShouldPass() { + assertThat(cookies()).hasValue("framework", "spring"); + } + + @Test + void hasValueEqualsWhenCookieValueDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).hasValue("framework", "other")); + } + + @Test + void hasCookieSatisfyingWhenCookieValueMatchesShouldPass() { + assertThat(cookies()).hasCookieSatisfying("framework", cookie -> assertThat(cookie.getValue()).startsWith("spr")); + } + + @Test + void hasCookieSatisfyingWhenCookieValueDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).hasCookieSatisfying( + "framework", cookie -> assertThat(cookie.getValue()).startsWith("not"))); + } + + @Test + void hasMaxAgeWhenCookieAgeMatchesShouldPass() { + assertThat(cookies()).hasMaxAge("age", Duration.ofMinutes(20)); + } + + @Test + void hasMaxAgeWhenCookieAgeDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).hasMaxAge("age", Duration.ofMinutes(30))); + } + + @Test + void pathWhenCookiePathMatchesShouldPass() { + assertThat(cookies()).hasPath("path", "/spring"); + } + + @Test + void pathWhenCookiePathDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).hasPath("path", "/other")); + } + + @Test + void hasDomainWhenCookieDomainMatchesShouldPass() { + assertThat(cookies()).hasDomain("domain", "spring.io"); + } + + @Test + void hasDomainWhenCookieDomainDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).hasDomain("domain", "example.org")); + } + + @Test + void isSecureWhenCookieSecureMatchesShouldPass() { + assertThat(cookies()).isSecure("framework", true); + } + + @Test + void isSecureWhenCookieSecureDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).isSecure("domain", true)); + } + + @Test + void isHttpOnlyWhenCookieHttpOnlyMatchesShouldPass() { + assertThat(cookies()).isHttpOnly("framework", true); + } + + @Test + void isHttpOnlyWhenCookieHttpOnlyDiffersShouldFail() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertThat(cookies()).isHttpOnly("domain", true)); + } + + private static AssertProvider cookies() { + return () -> new ResponseCookieMapAssert(cookies); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseTests.java new file mode 100644 index 00000000000..8d9792cb863 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/assertj/WebTestClientResponseTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2002-present 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 + * + * https://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.assertj; + + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link WebTestClientResponse}. + * + * @author Rossen Stoyanchev + */ +public class WebTestClientResponseTests { + + private final WebTestClient client = WebTestClient.bindToController(HelloController.class).build(); + + + @Test + void status() { + ResponseSpec spec = client.get().uri("/greeting").exchange(); + assertThat(WebTestClientResponse.from(spec)).hasStatusOk().hasStatus2xxSuccessful(); + } + + @Test + void headers() { + ResponseSpec spec = client.get().uri("/greeting").exchange(); + + WebTestClientResponse response = WebTestClientResponse.from(spec); + assertThat(response).hasStatusOk(); + assertThat(response).headers() + .containsOnlyHeaders(HttpHeaders.CONTENT_TYPE, HttpHeaders.CONTENT_LENGTH) + .hasValue(HttpHeaders.CONTENT_TYPE, "text/plain;charset=UTF-8") + .hasValue(HttpHeaders.CONTENT_LENGTH, 11); + } + + @Test + void contentType() { + ResponseSpec spec = client.get().uri("/greeting").exchange(); + + WebTestClientResponse response = WebTestClientResponse.from(spec); + assertThat(response).hasStatusOk(); + assertThat(response).contentType().isEqualTo("text/plain;charset=UTF-8"); + assertThat(response).hasContentTypeCompatibleWith(MediaType.TEXT_PLAIN); + } + + @Test + void cookies() { + ResponseSpec spec = client.get().uri("/cookie").exchange(); + + WebTestClientResponse response = WebTestClientResponse.from(spec); + assertThat(response).hasStatusOk(); + assertThat(response).cookies().hasValue("foo", "bar"); + assertThat(response).body().isEmpty(); + } + + @Test + void bodyText() { + ResponseSpec spec = client.get().uri("/greeting").exchange(); + + WebTestClientResponse response = WebTestClientResponse.from(spec); + assertThat(response).hasStatusOk(); + assertThat(response).contentType().isCompatibleWith(MediaType.TEXT_PLAIN); + assertThat(response).bodyText().isEqualTo("Hello World"); + assertThat(response).hasBodyTextEqualTo("Hello World"); + } + + @Test + void bodyJson() { + ResponseSpec spec = client.get().uri("/message").exchange(); + + WebTestClientResponse response = WebTestClientResponse.from(spec); + assertThat(response).hasStatusOk(); + assertThat(response).contentType().isEqualTo(MediaType.APPLICATION_JSON); + assertThat(response).bodyJson().extractingPath("$.message").asString().isEqualTo("Hello World"); + } + + + @SuppressWarnings("unused") + @RestController + private static class HelloController { + + @GetMapping("/greeting") + public String getGreeting() { + return "Hello World"; + } + + @GetMapping("/message") + public Map getMessage() { + return Map.of("message", "Hello World"); + } + + @GetMapping("/cookie") + public void getCookie(ServerWebExchange exchange) { + ResponseCookie cookie = ResponseCookie.from("foo", "bar").build(); + exchange.getResponse().addCookie(cookie); + } + } + +}