diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java index 327ad3de13e..e95fb538f35 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/DefaultRestTestClient.java @@ -38,6 +38,8 @@ import org.springframework.http.MediaType; import org.springframework.http.client.ClientHttpRequestExecution; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.converter.HttpMessageConverters; +import org.springframework.test.http.HttpMessageContentConverter; import org.springframework.test.json.JsonAssert; import org.springframework.test.json.JsonComparator; import org.springframework.test.json.JsonCompareMode; @@ -67,6 +69,8 @@ class DefaultRestTestClient implements RestTestClient { private final DefaultRestTestClientBuilder> restTestClientBuilder; + private final @Nullable HttpMessageContentConverter messageContentConverter; + private final AtomicLong requestIndex = new AtomicLong(); @@ -77,6 +81,7 @@ class DefaultRestTestClient implements RestTestClient { this.restClient = builder.requestInterceptor(this.wiretapInterceptor).build(); this.entityResultConsumer = entityResultConsumer; this.restTestClientBuilder = restTestClientBuilder; + this.messageContentConverter = new ConverterCallback(this.restClient).getConverter(); } @@ -131,6 +136,27 @@ class DefaultRestTestClient implements RestTestClient { } + private static class ConverterCallback { + + private @Nullable HttpMessageContentConverter converter; + + ConverterCallback(RestClient client) { + client.mutate() + .configureMessageConverters(convertersBuilder -> { + HttpMessageConverters converters = convertersBuilder.build(); + if (converters.iterator().hasNext()) { + this.converter = HttpMessageContentConverter.of(converters); + } + }) + .build(); + } + + public @Nullable HttpMessageContentConverter getConverter() { + return this.converter; + } + } + + private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; @@ -263,7 +289,8 @@ class DefaultRestTestClient implements RestTestClient { this.requestHeadersUriSpec.exchangeForRequiredValue( (request, response) -> { byte[] requestBody = wiretapInterceptor.getRequestContent(this.requestId); - return new ExchangeResult(request, response, this.uriTemplate, requestBody); + return new ExchangeResult( + request, response, this.uriTemplate, requestBody, messageContentConverter); }, false), DefaultRestTestClient.this.entityResultConsumer); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java index 99cf979c0fa..c86b4d352ec 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ExchangeResult.java @@ -38,6 +38,7 @@ 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.HttpMessageContentConverter; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -74,13 +75,15 @@ public class ExchangeResult { private final byte[] requestBody; + private final @Nullable HttpMessageContentConverter messageContentConverter; + /** Ensure single logging; for example, for expectAll. */ private boolean diagnosticsLogged; ExchangeResult( HttpRequest request, ConvertibleClientHttpResponse response, @Nullable String uriTemplate, - byte[] requestBody) { + byte[] requestBody, @Nullable HttpMessageContentConverter messageContentConverter) { Assert.notNull(request, "HttpRequest must not be null"); Assert.notNull(response, "ClientHttpResponse must not be null"); @@ -88,10 +91,11 @@ public class ExchangeResult { this.clientResponse = response; this.uriTemplate = uriTemplate; this.requestBody = requestBody; + this.messageContentConverter = messageContentConverter; } ExchangeResult(ExchangeResult result) { - this(result.request, result.clientResponse, result.uriTemplate, result.requestBody); + this(result.request, result.clientResponse, result.uriTemplate, result.requestBody, result.messageContentConverter); this.diagnosticsLogged = result.diagnosticsLogged; } @@ -197,6 +201,14 @@ public class ExchangeResult { } } + /** + * Return a content converter that delegates to the configured HTTP message converters. + * Mainly for internal use from AssertJ support. + */ + public @Nullable HttpMessageContentConverter getMessageContentConverter() { + return this.messageContentConverter; + } + /** * 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/servlet/client/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java index 9a9af7b797c..8c0e84a0150 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/RestTestClient.java @@ -647,13 +647,24 @@ public interface RestTestClient { BodyContentSpec expectBody(); /** - * Exit the chained flow in order to consume the response body externally. + * Return an {@link ExchangeResult} with the raw content. Effectively, a shortcut for: + *
+ * .returnResult(byte[].class) + *+ */ + default ExchangeResult returnResult() { + return returnResult(byte[].class); + } + + /** + * Convert the response content to the given target type, and return an + * {@link ExchangeResult} that represents the exchange. */
+ * ResponseSpec spec = restTestClient.get().uri("/greeting").exchange();
+ *
+ * RestTestClientResponse response = RestTestClientResponse.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 RestTestClientResponse extends AssertProvider
+ * // 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 RestTestClientResponseAssert 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 RestTestClientResponseAssert 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 RestTestClientResponseAssert 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 RestTestClientResponseAssert 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 RestTestClientResponseAssert 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 RestTestClientResponseAssert 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 RestTestClientResponseAssert hasContentTypeCompatibleWith(String contentType) {
+ contentType().isCompatibleWith(contentType);
+ return this.myself;
+ }
+
+ /**
+ * Return a new {@linkplain CookieMapAssert assertion} object that uses the
+ * response's {@linkplain Cookie cookies} as the object to test.
+ */
+ public CookieMapAssert cookies() {
+ return new CookieMapAssert(getCookies());
+ }
+
+ private Cookie[] getCookies() {
+ ListExamples:
+ * // 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 RestTestClientResponseAssert 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().getMessageContentConverter()));
+ }
+
+ 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/servlet/client/assertj/package-info.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/assertj/package-info.java
new file mode 100644
index 00000000000..918756c474a
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/assertj/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * AssertJ support for RestTestClient.
+ */
+@NullMarked
+package org.springframework.test.web.servlet.client.assertj;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/client/assertj/RestTestClientResponseTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/client/assertj/RestTestClientResponseTests.java
new file mode 100644
index 00000000000..1e61eb22109
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/client/assertj/RestTestClientResponseTests.java
@@ -0,0 +1,129 @@
+/*
+ * 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.servlet.client.assertj;
+
+
+import java.util.Map;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverters;
+import org.springframework.test.web.servlet.client.RestTestClient;
+import org.springframework.test.web.servlet.client.RestTestClient.ResponseSpec;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link RestTestClientResponse}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class RestTestClientResponseTests {
+
+ private final RestTestClient client =
+ RestTestClient.bindToController(HelloController.class)
+ .configureMessageConverters(HttpMessageConverters.Builder::registerDefaults)
+ .build();
+
+
+ @Test
+ void status() {
+ ResponseSpec spec = client.get().uri("/greeting").exchange();
+ assertThat(RestTestClientResponse.from(spec)).hasStatusOk().hasStatus2xxSuccessful();
+ }
+
+ @Test
+ void headers() {
+ RestTestClient.ResponseSpec spec = client.get().uri("/greeting").exchange();
+
+ RestTestClientResponse response = RestTestClientResponse.from(spec);
+ assertThat(response).hasStatusOk();
+ assertThat(response).headers()
+ .containsOnlyHeaders(HttpHeaders.CONTENT_TYPE, HttpHeaders.CONTENT_LENGTH)
+ .hasValue(HttpHeaders.CONTENT_TYPE, "text/plain;charset=ISO-8859-1")
+ .hasValue(HttpHeaders.CONTENT_LENGTH, 11);
+ }
+
+ @Test
+ void contentType() {
+ ResponseSpec spec = client.get().uri("/greeting").exchange();
+
+ RestTestClientResponse response = RestTestClientResponse.from(spec);
+ assertThat(response).hasStatusOk();
+ assertThat(response).contentType().isEqualTo("text/plain;charset=ISO-8859-1");
+ assertThat(response).hasContentTypeCompatibleWith(MediaType.TEXT_PLAIN);
+ }
+
+ @Test
+ void cookies() {
+ ResponseSpec spec = client.get().uri("/cookie").exchange();
+
+ RestTestClientResponse response = RestTestClientResponse.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();
+
+ RestTestClientResponse response = RestTestClientResponse.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();
+
+ RestTestClientResponse response = RestTestClientResponse.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