diff --git a/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java b/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java index 6c379b4a90f6..c495beea34f1 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/MockMvcClientHttpRequestFactory.java @@ -20,6 +20,9 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import jakarta.servlet.http.Cookie; +import org.jspecify.annotations.Nullable; + import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -31,6 +34,7 @@ import org.springframework.mock.http.client.MockClientHttpResponse; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -67,8 +71,14 @@ private ClientHttpResponse getClientHttpResponse( HttpMethod httpMethod, URI uri, HttpHeaders requestHeaders, byte[] requestBody) { try { + Cookie[] cookies = parseCookies(requestHeaders.get(HttpHeaders.COOKIE)); + MockHttpServletRequestBuilder requestBuilder = request(httpMethod, uri) + .content(requestBody).headers(requestHeaders); + if (cookies.length > 0) { + requestBuilder.cookie(cookies); + } MockHttpServletResponse servletResponse = this.mockMvc - .perform(request(httpMethod, uri).content(requestBody).headers(requestHeaders)) + .perform(requestBuilder) .andReturn() .getResponse(); @@ -92,6 +102,22 @@ private ClientHttpResponse getClientHttpResponse( } } + private static Cookie[] parseCookies(@Nullable List headerValues) { + if (headerValues == null) { + return new Cookie[0]; + } + return headerValues.stream() + .flatMap(header -> StringUtils.commaDelimitedListToSet(header).stream()) + .map(MockMvcClientHttpRequestFactory::parseCookie) + .toArray(Cookie[]::new); + } + + private static Cookie parseCookie(String cookie) { + String[] parts = StringUtils.split(cookie, "="); + Assert.isTrue(parts != null && parts.length == 2, "Invalid cookie: '" + cookie + "'"); + return new Cookie(parts[0], parts[1]); + } + private HttpHeaders getResponseHeaders(MockHttpServletResponse response) { HttpHeaders headers = new HttpHeaders(); for (String name : response.getHeaderNames()) { diff --git a/spring-test/src/main/java/org/springframework/test/web/server/AbstractMockMvcServerSpec.java b/spring-test/src/main/java/org/springframework/test/web/server/AbstractMockMvcServerSpec.java new file mode 100644 index 000000000000..135667e726f0 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/AbstractMockMvcServerSpec.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2025 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.server; + +import jakarta.servlet.Filter; + +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.test.web.client.MockMvcClientHttpRequestFactory; +import org.springframework.test.web.servlet.DispatcherServletCustomizer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcConfigurer; + +/** + * Base class for implementations of {@link RestTestClient.MockMvcServerSpec} + * that simply delegates to a {@link org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder} supplied by + * the concrete subclasses. + * + * @author Rob Worsnop + * @param the type of the concrete subclass spec + */ +abstract class AbstractMockMvcServerSpec> + implements RestTestClient.MockMvcServerSpec { + + @Override + public T filters(Filter... filters) { + getMockMvcBuilder().addFilters(filters); + return self(); + } + + @Override + public final T filter(Filter filter, String... urlPatterns) { + getMockMvcBuilder().addFilter(filter, urlPatterns); + return self(); + } + + @Override + public T defaultRequest(RequestBuilder requestBuilder) { + getMockMvcBuilder().defaultRequest(requestBuilder); + return self(); + } + + @Override + public T alwaysExpect(ResultMatcher resultMatcher) { + getMockMvcBuilder().alwaysExpect(resultMatcher); + return self(); + } + + @Override + public T dispatchOptions(boolean dispatchOptions) { + getMockMvcBuilder().dispatchOptions(dispatchOptions); + return self(); + } + + @Override + public T dispatcherServletCustomizer(DispatcherServletCustomizer customizer) { + getMockMvcBuilder().addDispatcherServletCustomizer(customizer); + return self(); + } + + @Override + public T apply(MockMvcConfigurer configurer) { + getMockMvcBuilder().apply(configurer); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + /** + * Return the concrete {@link ConfigurableMockMvcBuilder} to delegate + * configuration methods and to use to create the {@link MockMvc}. + */ + protected abstract ConfigurableMockMvcBuilder getMockMvcBuilder(); + + @Override + public RestTestClient.Builder configureClient() { + MockMvc mockMvc = getMockMvcBuilder().build(); + ClientHttpRequestFactory requestFactory = new MockMvcClientHttpRequestFactory(mockMvc); + return RestTestClient.bindToServer(requestFactory); + } + + @Override + public RestTestClient build() { + return configureClient().build(); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/ApplicationContextMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/server/ApplicationContextMockMvcSpec.java new file mode 100644 index 000000000000..1dea80ed46b9 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/ApplicationContextMockMvcSpec.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2025 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.server; + +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +/** + * Simple wrapper around a {@link DefaultMockMvcBuilder}. + * + * @author Rob Worsnop + */ +class ApplicationContextMockMvcSpec extends AbstractMockMvcServerSpec { + private final DefaultMockMvcBuilder mockMvcBuilder; + + public ApplicationContextMockMvcSpec(WebApplicationContext context) { + this.mockMvcBuilder = MockMvcBuilders.webAppContextSetup(context); + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/server/CookieAssertions.java new file mode 100644 index 000000000000..b0e50d0ab0cc --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/CookieAssertions.java @@ -0,0 +1,236 @@ +/* + * Copyright 2002-2025 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.server; + +import java.time.Duration; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.ResponseCookie; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on cookies of the response. + * + * @author Rob Worsnop + */ +public class CookieAssertions { + + private final ExchangeResult exchangeResult; + + private final RestTestClient.ResponseSpec responseSpec; + + public CookieAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + + /** + * Expect a response cookie with the given name to match the specified value. + */ + public RestTestClient.ResponseSpec valueEquals(String name, String value) { + String cookieValue = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertEquals(message, value, cookieValue); + }); + return this.responseSpec; + } + + /** + * Assert the value of the response cookie with the given name with a Hamcrest + * {@link Matcher}. + */ + public RestTestClient.ResponseSpec value(String name, Matcher matcher) { + String value = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + MatcherAssert.assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the value of the response cookie with the given name. + */ + public RestTestClient.ResponseSpec value(String name, Consumer consumer) { + String value = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is present. + */ + public RestTestClient.ResponseSpec exists(String name) { + getCookie(name); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is not present. + */ + public RestTestClient.ResponseSpec doesNotExist(String name) { + ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); + if (cookie != null) { + String message = getMessage(name) + " exists with value=[" + cookie.getValue() + "]"; + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Assert a cookie's "Max-Age" attribute. + */ + public RestTestClient.ResponseSpec maxAge(String name, Duration expected) { + Duration maxAge = getCookie(name).getMaxAge(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertEquals(message, expected, maxAge); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Max-Age" attribute with a Hamcrest {@link Matcher}. + */ + public RestTestClient.ResponseSpec maxAge(String name, Matcher matcher) { + long maxAge = getCookie(name).getMaxAge().getSeconds(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertThat(message, maxAge, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Path" attribute. + */ + public RestTestClient.ResponseSpec path(String name, String expected) { + String path = getCookie(name).getPath(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Path" attribute with a Hamcrest {@link Matcher}. + */ + public RestTestClient.ResponseSpec path(String name, Matcher matcher) { + String path = getCookie(name).getPath(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertThat(message, path, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Domain" attribute. + */ + public RestTestClient.ResponseSpec domain(String name, String expected) { + String path = getCookie(name).getDomain(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Domain" attribute with a Hamcrest {@link Matcher}. + */ + public RestTestClient.ResponseSpec domain(String name, Matcher matcher) { + String domain = getCookie(name).getDomain(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertThat(message, domain, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Secure" attribute. + */ + public RestTestClient.ResponseSpec secure(String name, boolean expected) { + boolean isSecure = getCookie(name).isSecure(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " secure"; + assertEquals(message, expected, isSecure); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "HttpOnly" attribute. + */ + public RestTestClient.ResponseSpec httpOnly(String name, boolean expected) { + boolean isHttpOnly = getCookie(name).isHttpOnly(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " httpOnly"; + assertEquals(message, expected, isHttpOnly); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "Partitioned" attribute. + */ + public RestTestClient.ResponseSpec partitioned(String name, boolean expected) { + boolean isPartitioned = getCookie(name).isPartitioned(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " isPartitioned"; + assertEquals(message, expected, isPartitioned); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's "SameSite" attribute. + */ + public RestTestClient.ResponseSpec sameSite(String name, String expected) { + String sameSite = getCookie(name).getSameSite(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " sameSite"; + assertEquals(message, expected, sameSite); + }); + return this.responseSpec; + } + + private ResponseCookie getCookie(String name) { + ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); + if (cookie != null) { + return cookie; + } + else { + this.exchangeResult.assertWithDiagnostics(() -> fail("No cookie with name '" + name + "'")); + } + throw new IllegalStateException("This code path should not be reachable"); + } + + private static String getMessage(String cookie) { + return "Response cookie '" + cookie + "'"; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/DefaultRestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/server/DefaultRestTestClient.java new file mode 100644 index 000000000000..58c3abd635c6 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/DefaultRestTestClient.java @@ -0,0 +1,432 @@ +/* + * Copyright 2002-2025 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.server; + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.json.JsonAssert; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.util.AssertionErrors; +import org.springframework.test.util.ExceptionCollector; +import org.springframework.test.util.XmlExpectationsHelper; +import org.springframework.util.MimeType; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilder; + +/** + * Default implementation of {@link RestTestClient}. + * + * @author Rob Worsnop + */ +class DefaultRestTestClient implements RestTestClient { + + private final RestClient restClient; + + private final AtomicLong requestIndex = new AtomicLong(); + + private final RestClient.Builder restClientBuilder; + + DefaultRestTestClient(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder.build(); + this.restClientBuilder = restClientBuilder; + } + + @Override + public RequestHeadersUriSpec get() { + return methodInternal(HttpMethod.GET); + } + + @Override + public RequestHeadersUriSpec head() { + return methodInternal(HttpMethod.HEAD); + } + + @Override + public RequestBodyUriSpec post() { + return methodInternal(HttpMethod.POST); + } + + @Override + public RequestBodyUriSpec put() { + return methodInternal(HttpMethod.PUT); + } + + @Override + public RequestBodyUriSpec patch() { + return methodInternal(HttpMethod.PATCH); + } + + @Override + public RequestHeadersUriSpec delete() { + return methodInternal(HttpMethod.DELETE); + } + + @Override + public RequestHeadersUriSpec options() { + return methodInternal(HttpMethod.OPTIONS); + } + + @Override + public RequestBodyUriSpec method(HttpMethod method) { + return methodInternal(method); + } + + @Override + public Builder mutate() { + return new DefaultRestTestClientBuilder(this.restClientBuilder); + } + + private RequestBodyUriSpec methodInternal(HttpMethod httpMethod) { + return new DefaultRequestBodyUriSpec(this.restClient.method(httpMethod)); + } + + + private class DefaultRequestBodyUriSpec implements RequestBodyUriSpec { + + private final RestClient.RequestBodyUriSpec requestHeadersUriSpec; + private RestClient.RequestBodySpec requestBodySpec; + private final String requestId; + + + public DefaultRequestBodyUriSpec(RestClient.RequestBodyUriSpec spec) { + this.requestHeadersUriSpec = spec; + this.requestBodySpec = spec; + this.requestId = String.valueOf(requestIndex.incrementAndGet()); + } + + @Override + public RequestBodySpec accept(MediaType... acceptableMediaTypes) { + this.requestBodySpec = this.requestHeadersUriSpec.accept(acceptableMediaTypes); + return this; + } + + @Override + public RequestBodySpec uri(URI uri) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri); + return this; + } + + @Override + public RequestBodySpec uri(String uriTemplate, Object... uriVariables) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uriTemplate, uriVariables); + return this; + } + + @Override + public RequestBodySpec uri(String uri, Map uriVariables) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables); + return this; + } + + @Override + public RequestBodySpec uri(Function uriFunction) { + this.requestBodySpec = this.requestHeadersUriSpec.uri(uriFunction); + return this; + } + + @Override + public RequestBodySpec cookie(String name, String value) { + this.requestBodySpec = this.requestHeadersUriSpec.cookie(name, value); + return this; + } + + @Override + public RequestBodySpec cookies(Consumer> cookiesConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.cookies(cookiesConsumer); + return this; + } + + @Override + public RequestBodySpec header(String headerName, String... headerValues) { + this.requestBodySpec = this.requestHeadersUriSpec.header(headerName, headerValues); + return this; + } + + @Override + public RequestBodySpec contentType(MediaType contentType) { + this.requestBodySpec = this.requestHeadersUriSpec.contentType(contentType); + return this; + } + + @Override + public RequestHeadersSpec body(Object body) { + this.requestHeadersUriSpec.body(body); + return this; + } + + @Override + public RequestBodySpec acceptCharset(Charset... acceptableCharsets) { + this.requestBodySpec = this.requestHeadersUriSpec.acceptCharset(acceptableCharsets); + return this; + } + + @Override + public RequestBodySpec ifModifiedSince(ZonedDateTime ifModifiedSince) { + this.requestBodySpec = this.requestHeadersUriSpec.ifModifiedSince(ifModifiedSince); + return this; + } + + @Override + public RequestBodySpec ifNoneMatch(String... ifNoneMatches) { + this.requestBodySpec = this.requestHeadersUriSpec.ifNoneMatch(ifNoneMatches); + return this; + } + + @Override + public RequestBodySpec headers(Consumer headersConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.headers(headersConsumer); + return this; + } + + @Override + public RequestBodySpec attribute(String name, Object value) { + this.requestBodySpec = this.requestHeadersUriSpec.attribute(name, value); + return this; + } + + @Override + public RequestBodySpec attributes(Consumer> attributesConsumer) { + this.requestBodySpec = this.requestHeadersUriSpec.attributes(attributesConsumer); + return this; + } + + @Override + public ResponseSpec exchange() { + this.requestBodySpec = this.requestBodySpec.header(RESTTESTCLIENT_REQUEST_ID, this.requestId); + ExchangeResult exchangeResult = this.requestBodySpec.exchange( + (clientRequest, clientResponse) -> new ExchangeResult(clientResponse), + false); + return new DefaultResponseSpec(Objects.requireNonNull(exchangeResult)); + } + } + + private static class DefaultResponseSpec implements ResponseSpec { + + private final ExchangeResult exchangeResult; + + public DefaultResponseSpec(ExchangeResult exchangeResult) { + this.exchangeResult = exchangeResult; + } + + @Override + public StatusAssertions expectStatus() { + return new StatusAssertions(this.exchangeResult, this); + } + + @Override + public BodyContentSpec expectBody() { + byte[] body = this.exchangeResult.getBody(byte[].class); + return new DefaultBodyContentSpec( new EntityExchangeResult<>(this.exchangeResult, body)); + } + + @Override + public BodySpec expectBody(Class bodyType) { + B body = this.exchangeResult.getBody(bodyType); + return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body)); + } + + @Override + public BodySpec expectBody(ParameterizedTypeReference bodyType) { + B body = this.exchangeResult.getBody(bodyType); + return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body)); + } + + @Override + public CookieAssertions expectCookie() { + return new CookieAssertions(this.exchangeResult, this); + } + + @Override + public HeaderAssertions expectHeader() { + return new HeaderAssertions(this.exchangeResult, this); + } + + @Override + public ResponseSpec expectAll(ResponseSpecConsumer... consumers) { + ExceptionCollector exceptionCollector = new ExceptionCollector(); + for (ResponseSpecConsumer consumer : consumers) { + exceptionCollector.execute(() -> consumer.accept(this)); + } + try { + exceptionCollector.assertEmpty(); + } + catch (RuntimeException ex) { + throw ex; + } + catch (Exception ex) { + // In theory, a ResponseSpecConsumer should never throw an Exception + // that is not a RuntimeException, but since ExceptionCollector may + // throw a checked Exception, we handle this to appease the compiler + // and in case someone uses a "sneaky throws" technique. + throw new AssertionError(ex.getMessage(), ex); + } + return this; + } + + @Override + public EntityExchangeResult returnResult(Class elementClass) { + return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass)); + } + + @Override + public EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef) { + return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef)); + } + } + + private static class DefaultBodyContentSpec implements BodyContentSpec { + private final EntityExchangeResult result; + + public DefaultBodyContentSpec(EntityExchangeResult result) { + this.result = result; + } + + @Override + public EntityExchangeResult isEmpty() { + this.result.assertWithDiagnostics(() -> + AssertionErrors.assertTrue("Expected empty body", + this.result.getBody(byte[].class) == null)); + return new EntityExchangeResult<>(this.result, null); + } + + @Override + public BodyContentSpec json(String expectedJson, JsonCompareMode compareMode) { + return json(expectedJson, JsonAssert.comparator(compareMode)); + } + + @Override + public BodyContentSpec json(String expectedJson, JsonComparator comparator) { + this.result.assertWithDiagnostics(() -> { + try { + comparator.assertIsMatch(expectedJson, getBodyAsString()); + } + catch (Exception ex) { + throw new AssertionError("JSON parsing error", ex); + } + }); + return this; + } + + @Override + public BodyContentSpec xml(String expectedXml) { + this.result.assertWithDiagnostics(() -> { + try { + new XmlExpectationsHelper().assertXmlEqual(expectedXml, getBodyAsString()); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + }); + return this; + } + + @Override + public JsonPathAssertions jsonPath(String expression) { + return new JsonPathAssertions(this, getBodyAsString(), expression, null); + } + + @Override + public XpathAssertions xpath(String expression, @Nullable Map namespaces, Object... args) { + return new XpathAssertions(this, expression, namespaces, args); + } + + private String getBodyAsString() { + byte[] body = this.result.getResponseBody(); + if (body == null || body.length == 0) { + return ""; + } + Charset charset = Optional.ofNullable(this.result.getResponseHeaders().getContentType()) + .map(MimeType::getCharset).orElse(StandardCharsets.UTF_8); + return new String(body, charset); + } + + @Override + public EntityExchangeResult returnResult() { + return this.result; + } + } + + private static class DefaultBodySpec> implements BodySpec { + + private final EntityExchangeResult result; + + public DefaultBodySpec(@Nullable EntityExchangeResult result) { + this.result = Objects.requireNonNull(result, "exchangeResult must be non-null"); + } + + @Override + public ResponseEntity returnResult() { + return ResponseEntity.status(this.result.getStatus()) + .headers(this.result.getResponseHeaders()) + .body(this.result.getResponseBody()); + } + + @Override + public T isEqualTo(B expected) { + this.result.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response body", expected, this.result.getResponseBody())); + return self(); + } + + @Override + @SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1129 + public T value(Function bodyMapper, Matcher matcher) { + this.result.assertWithDiagnostics(() -> { + B body = this.result.getResponseBody(); + MatcherAssert.assertThat(bodyMapper.apply(body), matcher); + }); + return self(); + } + + @Override + public T value(Consumer consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody())); + return self(); + } + + @Override + public T consumeWith(Consumer> consumer) { + this.result.assertWithDiagnostics(() -> consumer.accept(this.result)); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/DefaultRestTestClientBuilder.java b/spring-test/src/main/java/org/springframework/test/web/server/DefaultRestTestClientBuilder.java new file mode 100644 index 000000000000..466052b866b3 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/DefaultRestTestClientBuilder.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2025 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.server; + +import java.util.function.Consumer; + +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriBuilderFactory; + +/** + * Default implementation of {@link RestTestClient.Builder}. + * + * @author Rob Worsnop + */ +class DefaultRestTestClientBuilder implements RestTestClient.Builder { + + private final RestClient.Builder restClientBuilder; + + DefaultRestTestClientBuilder() { + this.restClientBuilder = RestClient.builder(); + } + + DefaultRestTestClientBuilder(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + } + + @Override + public RestTestClient.Builder apply(Consumer builderConsumer) { + builderConsumer.accept(this); + return this; + } + + @Override + public RestTestClient.Builder baseUrl(String baseUrl) { + this.restClientBuilder.baseUrl(baseUrl); + return this; + } + + @Override + public RestTestClient.Builder defaultCookie(String cookieName, String... cookieValues) { + this.restClientBuilder.defaultCookie(cookieName, cookieValues); + return this; + } + + @Override + public RestTestClient.Builder defaultCookies(Consumer> cookiesConsumer) { + this.restClientBuilder.defaultCookies(cookiesConsumer); + return this; + } + + @Override + public RestTestClient.Builder defaultHeader(String headerName, String... headerValues) { + this.restClientBuilder.defaultHeader(headerName, headerValues); + return this; + } + + @Override + public RestTestClient.Builder defaultHeaders(Consumer headersConsumer) { + this.restClientBuilder.defaultHeaders(headersConsumer); + return this; + } + + @Override + public RestTestClient.Builder uriBuilderFactory(UriBuilderFactory uriFactory) { + this.restClientBuilder.uriBuilderFactory(uriFactory); + return this; + } + + @Override + public RestTestClient build() { + return new DefaultRestTestClient(this.restClientBuilder); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/EntityExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/server/EntityExchangeResult.java new file mode 100644 index 000000000000..96547a298ad1 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/EntityExchangeResult.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2025 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.server; + +import org.jspecify.annotations.Nullable; + +/** + * {@code ExchangeResult} sub-class that exposes the response body fully + * extracted to a representation of type {@code }. + * + * @author Rob Worsnop + * @param the response body type + */ +public class EntityExchangeResult extends ExchangeResult { + + private final @Nullable T body; + + + EntityExchangeResult(ExchangeResult result, @Nullable T body) { + super(result); + this.body = body; + } + + + /** + * Return the entity extracted from the response body. + */ + public @Nullable T getResponseBody() { + return this.body; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/server/ExchangeResult.java new file mode 100644 index 000000000000..3ebabbc2594f --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/ExchangeResult.java @@ -0,0 +1,135 @@ +/* + * Copyright 2002-2025 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.server; + +import java.io.IOException; +import java.net.HttpCookie; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseCookie; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse; + +/** + * Container for request and response details for exchanges performed through + * {@link RestTestClient}. + * + * @author Rob Worsnop + */ +public class ExchangeResult { + private static final Pattern SAME_SITE_PATTERN = Pattern.compile("(?i).*SameSite=(Strict|Lax|None).*"); + private static final Pattern PARTITIONED_PATTERN = Pattern.compile("(?i).*;\\s*Partitioned(\\s*;.*|\\s*)$"); + + + private static final Log logger = LogFactory.getLog(ExchangeResult.class); + + /** Ensure single logging; for example, for expectAll. */ + private boolean diagnosticsLogged; + + private final ConvertibleClientHttpResponse clientResponse; + + ExchangeResult(@Nullable ConvertibleClientHttpResponse clientResponse) { + this.clientResponse = Objects.requireNonNull(clientResponse, "clientResponse must be non-null"); + } + + ExchangeResult(ExchangeResult result) { + this(result.clientResponse); + this.diagnosticsLogged = result.diagnosticsLogged; + } + + public HttpStatusCode getStatus() { + try { + return this.clientResponse.getStatusCode(); + } + catch (IOException ex) { + throw new AssertionError(ex); + } + } + + public HttpHeaders getResponseHeaders() { + return this.clientResponse.getHeaders(); + } + + @Nullable + public T getBody(Class bodyType) { + return this.clientResponse.bodyTo(bodyType); + } + + @Nullable + public T getBody(ParameterizedTypeReference bodyType) { + return this.clientResponse.bodyTo(bodyType); + } + + + /** + * Execute the given Runnable, catch any {@link AssertionError}, log details + * about the request and response at ERROR level under the class log + * category, and after that re-throw the error. + */ + public void assertWithDiagnostics(Runnable assertion) { + try { + assertion.run(); + } + catch (AssertionError ex) { + if (!this.diagnosticsLogged && logger.isErrorEnabled()) { + this.diagnosticsLogged = true; + logger.error("Request details for assertion failure:\n" + this); + } + throw ex; + } + } + + /** + * Return response cookies received from the server. + */ + public MultiValueMap getResponseCookies() { + return Optional.ofNullable(this.clientResponse.getHeaders().get(HttpHeaders.SET_COOKIE)).orElse(List.of()).stream() + .flatMap(header -> { + Matcher matcher = SAME_SITE_PATTERN.matcher(header); + String sameSite = (matcher.matches() ? matcher.group(1) : null); + boolean partitioned = PARTITIONED_PATTERN.matcher(header).matches(); + return HttpCookie.parse(header).stream().map(cookie -> toResponseCookie(cookie, sameSite, partitioned)); + }) + .collect(LinkedMultiValueMap::new, + (cookies, cookie) -> cookies.add(cookie.getName(), cookie), + LinkedMultiValueMap::addAll); + } + + private static ResponseCookie toResponseCookie(HttpCookie cookie, @Nullable String sameSite, boolean partitioned) { + return ResponseCookie.from(cookie.getName(), cookie.getValue()) + .domain(cookie.getDomain()) + .httpOnly(cookie.isHttpOnly()) + .maxAge(cookie.getMaxAge()) + .path(cookie.getPath()) + .secure(cookie.getSecure()) + .sameSite(sameSite) + .partitioned(partitioned) + .build(); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/server/HeaderAssertions.java new file mode 100644 index 000000000000..9d2eadcefa6d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/HeaderAssertions.java @@ -0,0 +1,311 @@ +/* + * Copyright 2002-2025 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.server; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNotNull; +import static org.springframework.test.util.AssertionErrors.assertTrue; +import static org.springframework.test.util.AssertionErrors.fail; + +/** + * Assertions on headers of the response. + * + * @author Rob Worsnop + * @see RestTestClient.ResponseSpec#expectHeader() + */ +public class HeaderAssertions { + + private final ExchangeResult exchangeResult; + + private final RestTestClient.ResponseSpec responseSpec; + + public HeaderAssertions(ExchangeResult exchangeResult, RestTestClient.ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + /** + * Expect a header with the given name to match the specified values. + */ + public RestTestClient.ResponseSpec valueEquals(String headerName, String... values) { + return assertHeader(headerName, Arrays.asList(values), getHeaders().getOrEmpty(headerName)); + } + + /** + * Expect a header with the given name to match the given long value. + */ + public RestTestClient.ResponseSpec valueEquals(String headerName, long value) { + String actual = getHeaders().getFirst(headerName); + this.exchangeResult.assertWithDiagnostics(() -> + assertNotNull("Response does not contain header '" + headerName + "'", actual)); + return assertHeader(headerName, value, Long.parseLong(actual)); + } + + /** + * Expect a header with the given name to match the specified long value + * parsed into a date using the preferred date format described in RFC 7231. + *

An {@link AssertionError} is thrown if the response does not contain + * the specified header, or if the supplied {@code value} does not match the + * primary header value. + */ + public RestTestClient.ResponseSpec valueEqualsDate(String headerName, long value) { + this.exchangeResult.assertWithDiagnostics(() -> { + String headerValue = getHeaders().getFirst(headerName); + assertNotNull("Response does not contain header '" + headerName + "'", headerValue); + + HttpHeaders headers = new HttpHeaders(); + headers.setDate("expected", value); + headers.set("actual", headerValue); + + assertEquals(getMessage(headerName) + "='" + headerValue + "' " + + "does not match expected value '" + headers.getFirst("expected") + "'", + headers.getFirstDate("expected"), headers.getFirstDate("actual")); + }); + return this.responseSpec; + } + + /** + * Match the first value of the response header with a regex. + * @param name the header name + * @param pattern the regex pattern + */ + public RestTestClient.ResponseSpec valueMatches(String name, String pattern) { + String value = getRequiredValue(name); + String message = getMessage(name) + "=[" + value + "] does not match [" + pattern + "]"; + this.exchangeResult.assertWithDiagnostics(() -> assertTrue(message, value.matches(pattern))); + return this.responseSpec; + } + + /** + * Match all values of the response header with the given regex + * patterns which are applied to the values of the header in the + * same order. Note that the number of patterns must match the + * number of actual values. + * @param name the header name + * @param patterns one or more regex patterns, one per expected value + */ + public RestTestClient.ResponseSpec valuesMatch(String name, String... patterns) { + List values = getRequiredValues(name); + this.exchangeResult.assertWithDiagnostics(() -> { + assertTrue( + getMessage(name) + " has fewer or more values " + values + + " than number of patterns to match with " + Arrays.toString(patterns), + values.size() == patterns.length); + for (int i = 0; i < values.size(); i++) { + String value = values.get(i); + String pattern = patterns[i]; + assertTrue( + getMessage(name) + "[" + i + "]='" + value + "' does not match '" + pattern + "'", + value.matches(pattern)); + } + }); + return this.responseSpec; + } + + /** + * Assert the first value of the response header with a Hamcrest {@link Matcher}. + * @param name the header name + * @param matcher the matcher to use + */ + public RestTestClient.ResponseSpec value(String name, Matcher matcher) { + String value = getHeaders().getFirst(name); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Assert all values of the response header with a Hamcrest {@link Matcher}. + * @param name the header name + * @param matcher the matcher to use + */ + public RestTestClient.ResponseSpec values(String name, Matcher> matcher) { + List values = getHeaders().get(name); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertThat(message, values, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the first value of the named response header. + * @param name the header name + * @param consumer the consumer to use + */ + public RestTestClient.ResponseSpec value(String name, Consumer consumer) { + String value = getRequiredValue(name); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Consume all values of the named response header. + * @param name the header name + * @param consumer the consumer to use + */ + public RestTestClient.ResponseSpec values(String name, Consumer> consumer) { + List values = getRequiredValues(name); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(values)); + return this.responseSpec; + } + + /** + * Expect that the header with the given name is present. + */ + public RestTestClient.ResponseSpec exists(String name) { + if (!this.exchangeResult.getResponseHeaders().containsHeader(name)) { + String message = getMessage(name) + " does not exist"; + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Expect that the header with the given name is not present. + */ + public RestTestClient.ResponseSpec doesNotExist(String name) { + if (getHeaders().containsHeader(name)) { + String message = getMessage(name) + " exists with value=[" + getHeaders().getFirst(name) + "]"; + this.exchangeResult.assertWithDiagnostics(() -> fail(message)); + } + return this.responseSpec; + } + + /** + * Expect a "Cache-Control" header with the given value. + */ + public RestTestClient.ResponseSpec cacheControl(CacheControl cacheControl) { + return assertHeader("Cache-Control", cacheControl.getHeaderValue(), getHeaders().getCacheControl()); + } + + /** + * Expect a "Content-Disposition" header with the given value. + */ + public RestTestClient.ResponseSpec contentDisposition(ContentDisposition contentDisposition) { + return assertHeader("Content-Disposition", contentDisposition, getHeaders().getContentDisposition()); + } + + /** + * Expect a "Content-Length" header with the given value. + */ + public RestTestClient.ResponseSpec contentLength(long contentLength) { + return assertHeader("Content-Length", contentLength, getHeaders().getContentLength()); + } + + /** + * Expect a "Content-Type" header with the given value. + */ + public RestTestClient.ResponseSpec contentType(MediaType mediaType) { + return assertHeader("Content-Type", mediaType, getHeaders().getContentType()); + } + + /** + * Expect a "Content-Type" header with the given value. + */ + public RestTestClient.ResponseSpec contentType(String mediaType) { + return contentType(MediaType.parseMediaType(mediaType)); + } + + /** + * Expect a "Content-Type" header compatible with the given value. + */ + public RestTestClient.ResponseSpec contentTypeCompatibleWith(MediaType mediaType) { + MediaType actual = getHeaders().getContentType(); + String message = getMessage("Content-Type") + "=[" + actual + "] is not compatible with [" + mediaType + "]"; + this.exchangeResult.assertWithDiagnostics(() -> + assertTrue(message, (actual != null && actual.isCompatibleWith(mediaType)))); + return this.responseSpec; + } + + /** + * Expect a "Content-Type" header compatible with the given value. + */ + public RestTestClient.ResponseSpec contentTypeCompatibleWith(String mediaType) { + return contentTypeCompatibleWith(MediaType.parseMediaType(mediaType)); + } + + /** + * Expect an "Expires" header with the given value. + */ + public RestTestClient.ResponseSpec expires(long expires) { + return assertHeader("Expires", expires, getHeaders().getExpires()); + } + + /** + * Expect a "Last-Modified" header with the given value. + */ + public RestTestClient.ResponseSpec lastModified(long lastModified) { + return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified()); + } + + /** + * Expect a "Location" header with the given value. + */ + public RestTestClient.ResponseSpec location(String location) { + return assertHeader("Location", URI.create(location), getHeaders().getLocation()); + } + + + private HttpHeaders getHeaders() { + return this.exchangeResult.getResponseHeaders(); + } + + private String getRequiredValue(String name) { + return getRequiredValues(name).get(0); + } + + private List getRequiredValues(String name) { + List values = getHeaders().get(name); + if (!CollectionUtils.isEmpty(values)) { + return values; + } + else { + this.exchangeResult.assertWithDiagnostics(() -> fail(getMessage(name) + " not found")); + } + throw new IllegalStateException("This code path should not be reachable"); + } + + private RestTestClient.ResponseSpec assertHeader(String name, @Nullable Object expected, @Nullable Object actual) { + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + assertEquals(message, expected, actual); + }); + return this.responseSpec; + } + + private static String getMessage(String headerName) { + return "Response header '" + headerName + "'"; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/JsonPathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/server/JsonPathAssertions.java new file mode 100644 index 000000000000..c6b9e98578da --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/JsonPathAssertions.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-2025 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.server; + +import java.util.function.Consumer; + +import com.jayway.jsonpath.Configuration; +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.test.util.JsonPathExpectationsHelper; +import org.springframework.util.Assert; + +/** + * JsonPath assertions. + * + * @author Rob Worsnop + * + * @see https://github.com/jayway/JsonPath + * @see JsonPathExpectationsHelper + */ +public class JsonPathAssertions { + + private final RestTestClient.BodyContentSpec bodySpec; + + private final String content; + + private final JsonPathExpectationsHelper pathHelper; + + + JsonPathAssertions(RestTestClient.BodyContentSpec spec, String content, String expression, @Nullable Configuration configuration) { + Assert.hasText(expression, "expression must not be null or empty"); + this.bodySpec = spec; + this.content = content; + this.pathHelper = new JsonPathExpectationsHelper(expression, configuration); + } + + + /** + * Applies {@link JsonPathExpectationsHelper#assertValue(String, Object)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(Object expectedValue) { + this.pathHelper.assertValue(this.content, expectedValue); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#exists(String)}. + */ + public RestTestClient.BodyContentSpec exists() { + this.pathHelper.exists(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#doesNotExist(String)}. + */ + public RestTestClient.BodyContentSpec doesNotExist() { + this.pathHelper.doesNotExist(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsEmpty(String)}. + */ + public RestTestClient.BodyContentSpec isEmpty() { + this.pathHelper.assertValueIsEmpty(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsNotEmpty(String)}. + */ + public RestTestClient.BodyContentSpec isNotEmpty() { + this.pathHelper.assertValueIsNotEmpty(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#hasJsonPath}. + */ + public RestTestClient.BodyContentSpec hasJsonPath() { + this.pathHelper.hasJsonPath(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#doesNotHaveJsonPath}. + */ + public RestTestClient.BodyContentSpec doesNotHaveJsonPath() { + this.pathHelper.doesNotHaveJsonPath(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsBoolean(String)}. + */ + public RestTestClient.BodyContentSpec isBoolean() { + this.pathHelper.assertValueIsBoolean(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsNumber(String)}. + */ + public RestTestClient.BodyContentSpec isNumber() { + this.pathHelper.assertValueIsNumber(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsArray(String)}. + */ + public RestTestClient.BodyContentSpec isArray() { + this.pathHelper.assertValueIsArray(this.content); + return this.bodySpec; + } + + /** + * Applies {@link JsonPathExpectationsHelper#assertValueIsMap(String)}. + */ + public RestTestClient.BodyContentSpec isMap() { + this.pathHelper.assertValueIsMap(this.content); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher)}. + */ + public RestTestClient.BodyContentSpec value(Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}. + */ + public RestTestClient.BodyContentSpec value(Class targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}. + */ + public RestTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Matcher matcher) { + this.pathHelper.assertValue(this.content, matcher, targetType); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation. + */ + @SuppressWarnings("unchecked") + public RestTestClient.BodyContentSpec value(Consumer consumer) { + Object value = this.pathHelper.evaluateJsonPath(this.content); + consumer.accept((T) value); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation and provide a target class. + */ + public RestTestClient.BodyContentSpec value(Class targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + + /** + * Consume the result of the JSONPath evaluation and provide a parameterized type. + */ + public RestTestClient.BodyContentSpec value(ParameterizedTypeReference targetType, Consumer consumer) { + T value = this.pathHelper.evaluateJsonPath(this.content, targetType); + consumer.accept(value); + return this.bodySpec; + } + + @Override + public boolean equals(@Nullable Object obj) { + throw new AssertionError("Object#equals is disabled " + + "to avoid being used in error instead of JsonPathAssertions#isEqualTo(String)."); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/RestTestClient.java b/spring-test/src/main/java/org/springframework/test/web/server/RestTestClient.java new file mode 100644 index 000000000000..1bc8f94c0580 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/RestTestClient.java @@ -0,0 +1,975 @@ +/* + * Copyright 2002-2025 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.server; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.servlet.Filter; +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.json.JsonComparator; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.json.JsonComparison; +import org.springframework.test.web.client.MockMvcClientHttpRequestFactory; +import org.springframework.test.web.servlet.DispatcherServletCustomizer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcConfigurer; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.Validator; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.client.RestClient; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.servlet.FlashMapManager; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.UriBuilder; +import org.springframework.web.util.UriBuilderFactory; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Client for testing web servers. + * + * @author Rob Worsnop + */ +public interface RestTestClient { + + /** + * The name of a request header used to assign a unique id to every request + * performed through the {@code RestTestClient}. This can be useful for + * storing contextual information at all phases of request processing (for example, + * from a server-side component) under that id and later to look up + * that information once an {@link ExchangeResult} is available. + */ + String RESTTESTCLIENT_REQUEST_ID = "RestTestClient-Request-Id"; + + /** + * Prepare an HTTP GET request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec get(); + + /** + * Prepare an HTTP HEAD request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec head(); + + /** + * Prepare an HTTP POST request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec post(); + + /** + * Prepare an HTTP PUT request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec put(); + + /** + * Prepare an HTTP PATCH request. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec patch(); + + /** + * Prepare an HTTP DELETE request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec delete(); + + /** + * Prepare an HTTP OPTIONS request. + * @return a spec for specifying the target URL + */ + RequestHeadersUriSpec options(); + + /** + * Prepare a request for the specified {@code HttpMethod}. + * @return a spec for specifying the target URL + */ + RequestBodyUriSpec method(HttpMethod method); + + /** + * Return a builder to mutate properties of this test client. + */ + Builder mutate(); + + + + /** + * Begin creating a {@link RestTestClient} by providing the {@code @Controller} + * instance(s) to handle requests with. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)} + * to initialize {@link MockMvc}. + */ + static ControllerSpec bindToController(Object... controllers) { + return new StandaloneMockMvcSpec(controllers); + } + + /** + * Begin creating a {@link RestTestClient} by providing the {@link RouterFunction} + * instance(s) to handle requests with. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#routerFunctions(RouterFunction[])} + * to initialize {@link MockMvc}. + */ + static RouterFunctionSpec bindToRouterFunction(RouterFunction... routerFunctions) { + return new RouterFunctionMockMvcSpec(routerFunctions); + } + + /** + * Begin creating a {@link RestTestClient} by providing a + * {@link WebApplicationContext} with Spring MVC infrastructure and + * controllers. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#webAppContextSetup(WebApplicationContext)} + * to initialize {@code MockMvc}. + */ + static MockMvcServerSpec bindToApplicationContext(WebApplicationContext context) { + return new ApplicationContextMockMvcSpec(context); + } + + + + /** + * Begin creating a {@link RestTestClient} by providing an already + * initialized {@link MockMvc} instance to use as the server. + */ + static RestTestClient.Builder bindTo(MockMvc mockMvc) { + ClientHttpRequestFactory requestFactory = new MockMvcClientHttpRequestFactory(mockMvc); + return RestTestClient.bindToServer(requestFactory); + } + + /** + * This server setup option allows you to connect to a live server through + * a client connector. + *

+	 * RestTestClient client = RestTestClient.bindToServer()
+	 *         .baseUrl("http://localhost:8080")
+	 *         .build();
+	 * 
+ * @return chained API to customize client config + */ + static Builder bindToServer() { + return new DefaultRestTestClientBuilder(); + } + + /** + * A variant of {@link #bindToServer()} with a pre-configured request factory. + * @return chained API to customize client config + */ + static Builder bindToServer(ClientHttpRequestFactory requestFactory) { + return new DefaultRestTestClientBuilder(RestClient.builder().requestFactory(requestFactory)); + } + + /** + * Specification for customizing controller configuration. + */ + interface ControllerSpec extends MockMvcServerSpec { + /** + * Register {@link org.springframework.web.bind.annotation.ControllerAdvice} + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setControllerAdvice(Object...)}. + */ + ControllerSpec controllerAdvice(Object... controllerAdvice); + + /** + * Set the message converters to use. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setMessageConverters(HttpMessageConverter[])}. + */ + ControllerSpec messageConverters(HttpMessageConverter... messageConverters); + + /** + * Provide a custom {@link Validator}. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setValidator(Validator)}. + */ + ControllerSpec validator(Validator validator); + + /** + * Provide a conversion service. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setConversionService(FormattingConversionService)}. + */ + ControllerSpec conversionService(FormattingConversionService conversionService); + + /** + * Add global interceptors. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#addInterceptors(HandlerInterceptor...)}. + */ + ControllerSpec interceptors(HandlerInterceptor... interceptors); + + /** + * Add interceptors for specific patterns. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#addMappedInterceptors(String[], HandlerInterceptor...)}. + */ + ControllerSpec mappedInterceptors( + String @Nullable [] pathPatterns, HandlerInterceptor... interceptors); + + /** + * Set a ContentNegotiationManager. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setContentNegotiationManager(ContentNegotiationManager)}. + */ + ControllerSpec contentNegotiationManager(ContentNegotiationManager manager); + + /** + * Specify the timeout value for async execution. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setAsyncRequestTimeout(long)}. + */ + ControllerSpec asyncRequestTimeout(long timeout); + + /** + * Provide custom argument resolvers. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setCustomArgumentResolvers(HandlerMethodArgumentResolver...)}. + */ + ControllerSpec customArgumentResolvers(HandlerMethodArgumentResolver... argumentResolvers); + + /** + * Provide custom return value handlers. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setCustomReturnValueHandlers(HandlerMethodReturnValueHandler...)}. + */ + ControllerSpec customReturnValueHandlers(HandlerMethodReturnValueHandler... handlers); + + /** + * Set the HandlerExceptionResolver types to use. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setHandlerExceptionResolvers(HandlerExceptionResolver...)}. + */ + ControllerSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers); + + /** + * Set up view resolution. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setViewResolvers(ViewResolver...)}. + */ + ControllerSpec viewResolvers(ViewResolver... resolvers); + + /** + * Set up a single {@link ViewResolver} with a fixed view. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setSingleView(View)}. + */ + ControllerSpec singleView(View view); + + /** + * Provide the LocaleResolver to use. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setLocaleResolver(LocaleResolver)}. + */ + ControllerSpec localeResolver(LocaleResolver localeResolver); + + /** + * Provide a custom FlashMapManager. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setFlashMapManager(FlashMapManager)}. + */ + ControllerSpec flashMapManager(FlashMapManager flashMapManager); + + /** + * Enable URL path matching with parsed + * {@link org.springframework.web.util.pattern.PathPattern PathPatterns}. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setPatternParser(PathPatternParser)}. + */ + ControllerSpec patternParser(PathPatternParser parser); + + /** + * Configure placeholder values to use. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#addPlaceholderValue(String, String)}. + */ + ControllerSpec placeholderValue(String name, String value); + + /** + * Configure factory for a custom {@link RequestMappingHandlerMapping}. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setCustomHandlerMapping(Supplier)}. + */ + ControllerSpec customHandlerMapping(Supplier factory); + } + + /** + * Specification for configuring {@link MockMvc} to test one or more + * {@linkplain RouterFunction router functions} + * directly, and a simple facade around {@link RouterFunctionMockMvcBuilder}. + */ + interface RouterFunctionSpec extends MockMvcServerSpec { + + /** + * Set the message converters to use. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setMessageConverters(HttpMessageConverter[])}. + */ + RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters); + + /** + * Add global interceptors. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#addInterceptors(HandlerInterceptor...)}. + */ + RouterFunctionSpec interceptors(HandlerInterceptor... interceptors); + + /** + * Add interceptors for specific patterns. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#addMappedInterceptors(String[], HandlerInterceptor...)}. + */ + RouterFunctionSpec mappedInterceptors( + String @Nullable [] pathPatterns, HandlerInterceptor... interceptors); + + /** + * Specify the timeout value for async execution. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setAsyncRequestTimeout(long)}. + */ + RouterFunctionSpec asyncRequestTimeout(long timeout); + + /** + * Set the HandlerExceptionResolver types to use. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setHandlerExceptionResolvers(HandlerExceptionResolver...)}. + */ + RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers); + + /** + * Set up view resolution. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setViewResolvers(ViewResolver...)}. + */ + RouterFunctionSpec viewResolvers(ViewResolver... resolvers); + + /** + * Set up a single {@link ViewResolver} with a fixed view. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setSingleView(View)}. + */ + RouterFunctionSpec singleView(View view); + + /** + * Enable URL path matching with parsed + * {@link org.springframework.web.util.pattern.PathPattern PathPatterns}. + *

This is delegated to + * {@link RouterFunctionMockMvcBuilder#setPatternParser(PathPatternParser)}. + */ + RouterFunctionSpec patternParser(PathPatternParser parser); + + } + + + /** + * Base specification for configuring {@link MockMvc}, and a simple facade + * around {@link ConfigurableMockMvcBuilder}. + * + * @param a self reference to the builder type + */ + interface MockMvcServerSpec> { + /** + * Add a global filter. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#addFilters(Filter...)}. + */ + T filters(Filter... filters); + + /** + * Add a filter for specific URL patterns. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#addFilter(Filter, String...)}. + */ + T filter(Filter filter, String... urlPatterns); + + /** + * Define default request properties that should be merged into all + * performed requests such that input from the client request override + * the default properties defined here. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#defaultRequest(RequestBuilder)}. + */ + T defaultRequest(RequestBuilder requestBuilder); + + /** + * Define a global expectation that should always be applied to + * every response. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#alwaysExpect(ResultMatcher)}. + */ + T alwaysExpect(ResultMatcher resultMatcher); + + /** + * Whether to handle HTTP OPTIONS requests. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#dispatchOptions(boolean)}. + */ + T dispatchOptions(boolean dispatchOptions); + + /** + * Allow customization of {@code DispatcherServlet}. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#addDispatcherServletCustomizer(DispatcherServletCustomizer)}. + */ + T dispatcherServletCustomizer(DispatcherServletCustomizer customizer); + + /** + * Add a {@code MockMvcConfigurer} that automates MockMvc setup. + *

This is delegated to + * {@link ConfigurableMockMvcBuilder#apply(MockMvcConfigurer)}. + */ + T apply(MockMvcConfigurer configurer); + + + /** + * Proceed to configure and build the test client. + */ + Builder configureClient(); + + /** + * Shortcut to build the test client. + */ + RestTestClient build(); + } + + /** + * Specification for providing request headers and the URI of a request. + * + * @param a self reference to the spec type + */ + interface RequestHeadersUriSpec> extends UriSpec, RequestHeadersSpec { + } + + /** + * Specification for providing the body and the URI of a request. + */ + interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec { + } + + /** + * Chained API for applying assertions to a response. + */ + interface ResponseSpec { + /** + * Assertions on the response status. + */ + StatusAssertions expectStatus(); + + /** + * Consume and decode the response body to {@code byte[]} and then apply + * assertions on the raw content (for example, isEmpty, JSONPath, etc.). + */ + BodyContentSpec expectBody(); + + /** + * Consume and decode the response body to a single object of type + * {@code } and then apply assertions. + * @param bodyType the expected body type + */ + BodySpec expectBody(Class bodyType); + + /** + * Alternative to {@link #expectBody(Class)} that accepts information + * about a target type with generics. + */ + BodySpec expectBody(ParameterizedTypeReference bodyType); + + /** + * Assertions on the cookies of the response. + */ + CookieAssertions expectCookie(); + + /** + * Assertions on the headers of the response. + */ + HeaderAssertions expectHeader(); + + /** + * Apply multiple assertions to a response with the given + * {@linkplain RestTestClient.ResponseSpec.ResponseSpecConsumer consumers}, with the guarantee that + * all assertions will be applied even if one or more assertions fails + * with an exception. + *

If a single {@link Error} or {@link RuntimeException} is thrown, + * it will be rethrown. + *

If multiple exceptions are thrown, this method will throw an + * {@link AssertionError} whose error message is a summary of all the + * exceptions. In addition, each exception will be added as a + * {@linkplain Throwable#addSuppressed(Throwable) suppressed exception} to + * the {@code AssertionError}. + *

This feature is similar to the {@code SoftAssertions} support in + * AssertJ and the {@code assertAll()} support in JUnit Jupiter. + * + *

Example

+ *
+		 * restTestClient.get().uri("/hello").exchange()
+		 *     .expectAll(
+		 *         responseSpec -> responseSpec.expectStatus().isOk(),
+		 *         responseSpec -> responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
+		 *     );
+		 * 
+ * @param consumers the list of {@code ResponseSpec} consumers + */ + ResponseSpec expectAll(ResponseSpecConsumer... consumers); + + /** + * Exit the chained flow in order to consume the response body + * externally. + */ + EntityExchangeResult returnResult(Class elementClass); + + /** + * Alternative to {@link #returnResult(Class)} that accepts information + * about a target type with generics. + */ + EntityExchangeResult returnResult(ParameterizedTypeReference elementTypeRef); + + /** + * {@link Consumer} of a {@link RestTestClient.ResponseSpec}. + * @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...) + */ + @FunctionalInterface + interface ResponseSpecConsumer extends Consumer { + } + } + + /** + * Spec for expectations on the response body content. + */ + interface BodyContentSpec { + /** + * Assert the response body is empty and return the exchange result. + */ + EntityExchangeResult isEmpty(); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison verifying that they contain the same attribute-value pairs + * regardless of formatting with lenient checking (extensible + * and non-strict array ordering). + *

Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @see #json(String, JsonCompareMode) + */ + default BodyContentSpec json(String expectedJson) { + return json(expectedJson, JsonCompareMode.LENIENT); + } + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@linkplain JsonCompareMode mode}. If the + * comparison failed, throws an {@link AssertionError} with the message + * of the {@link JsonComparison}. + *

Use of this method requires the + * JSONassert library + * to be on the classpath. + * @param expectedJson the expected JSON content + * @param compareMode the compare mode + * @see #json(String) + */ + BodyContentSpec json(String expectedJson, JsonCompareMode compareMode); + + /** + * Parse the expected and actual response content as JSON and perform a + * comparison using the given {@link JsonComparator}. If the comparison + * failed, throws an {@link AssertionError} with the message of the + * {@link JsonComparison}. + * @param expectedJson the expected JSON content + * @param comparator the comparator to use + */ + BodyContentSpec json(String expectedJson, JsonComparator comparator); + + /** + * Parse expected and actual response content as XML and assert that + * the two are "similar", i.e. they contain the same elements and + * attributes regardless of order. + *

Use of this method requires the + * XMLUnit library on + * the classpath. + * @param expectedXml the expected XML content. + * @see org.springframework.test.util.XmlExpectationsHelper#assertXmlEqual(String, String) + */ + BodyContentSpec xml(String expectedXml); + + /** + * Access to response body assertions using an XPath expression to + * inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression the XPath expression + * @param args arguments to parameterize the expression + * @see #xpath(String, Map, Object...) + */ + default XpathAssertions xpath(String expression, Object... args) { + return xpath(expression, null, args); + } + + /** + * Access to response body assertions with specific namespaces using an + * XPath expression to inspect a specific subset of the body. + *

The XPath expression can be a parameterized string using + * formatting specifiers as defined in {@link String#format}. + * @param expression the XPath expression + * @param namespaces the namespaces to use + * @param args arguments to parameterize the expression + */ + XpathAssertions xpath(String expression, @Nullable Map namespaces, Object... args); + + /** + * Access to response body assertions using a + * JsonPath expression + * to inspect a specific subset of the body. + * @param expression the JsonPath expression + */ + JsonPathAssertions jsonPath(String expression); + + /** + * Exit the chained API and return an {@code ExchangeResult} with the + * raw response content. + */ + EntityExchangeResult returnResult(); + } + + /** + * Spec for expectations on the response body decoded to a single Object. + * + * @param a self reference to the spec type + * @param the body type + */ + interface BodySpec> { + /** + * Transform the extracted the body with a function, for example, extracting a + * property, and assert the mapped value with a {@link Matcher}. + */ + T value(Function bodyMapper, Matcher matcher); + + /** + * Assert the extracted body with a {@link Consumer}. + */ + T value(Consumer consumer); + + /** + * Assert the exchange result with the given {@link Consumer}. + */ + T consumeWith(Consumer> consumer); + + /** + * Exit the chained API and return an {@code ResponseEntity} with the + * decoded response content. + */ + ResponseEntity returnResult(); + + /** + * Assert the extracted body is equal to the given value. + */ + T isEqualTo(B expected); + } + + /** + * Spec for expectations on the response body decoded to a List. + * + * @param the body list element type + */ + interface ListBodySpec extends BodySpec, ListBodySpec> { + + /** + * Assert the extracted list of values is of the given size. + * @param size the expected size + */ + ListBodySpec hasSize(int size); + + /** + * Assert the extracted list of values contains the given elements. + * @param elements the elements to check + */ + @SuppressWarnings("unchecked") + ListBodySpec contains(E... elements); + + /** + * Assert the extracted list of values doesn't contain the given elements. + * @param elements the elements to check + */ + @SuppressWarnings("unchecked") + ListBodySpec doesNotContain(E... elements); + } + + /** + * Specification for providing the URI of a request. + * + * @param a self reference to the spec type + */ + interface UriSpec> { + /** + * Specify the URI using an absolute, fully constructed {@link java.net.URI}. + *

If a {@link UriBuilderFactory} was configured for the client with + * a base URI, that base URI will not be applied to the + * supplied {@code java.net.URI}. If you wish to have a base URI applied to a + * {@code java.net.URI} you must invoke either {@link #uri(String, Object...)} + * or {@link #uri(String, Map)} — for example, {@code uri(myUri.toString())}. + * @return spec to add headers or perform the exchange + */ + S 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 (for example, + * with a base URI) it will be used to expand the URI template. + * @return spec to add headers or perform the exchange + */ + S 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 (for example, + * with a base URI) it will be used to expand the URI template. + * @return spec to add headers or perform the exchange + */ + S uri(String uri, Map uriVariables); + + /** + * Build the URI for the request with a {@link UriBuilder} obtained + * through the {@link UriBuilderFactory} configured for this client. + * @return spec to add headers or perform the exchange + */ + S uri(Function uriFunction); + + } + + + + + /** + * Specification for adding request headers and performing an exchange. + * + * @param a self reference to the spec type + */ + interface RequestHeadersSpec> { + + /** + * Set the list of acceptable {@linkplain MediaType media types}, as + * specified by the {@code Accept} header. + * @param acceptableMediaTypes the acceptable media types + * @return the same instance + */ + S 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 the same instance + */ + S acceptCharset(Charset... acceptableCharsets); + + /** + * Add a cookie with the given name and value. + * @param name the cookie name + * @param value the cookie value + * @return the same instance + */ + S cookie(String name, String value); + + /** + * Manipulate this request's cookies with the given consumer. The + * map provided to the consumer is "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, + * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies map + * @return this builder + */ + S cookies(Consumer> cookiesConsumer); + + /** + * 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 the same instance + */ + S ifModifiedSince(ZonedDateTime ifModifiedSince); + + /** + * Set the values of the {@code If-None-Match} header. + * @param ifNoneMatches the new value of the header + * @return the same instance + */ + S 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 the same instance + */ + S header(String headerName, String... headerValues); + + /** + * Manipulate the request's headers with the given consumer. The + * headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder + */ + S headers(Consumer headersConsumer); + + /** + * Set the attribute with the given name to the given value. + * @param name the name of the attribute to add + * @param value the value of the attribute to add + * @return this builder + */ + S attribute(String name, Object value); + + /** + * Manipulate the request attributes with the given consumer. The attributes provided to + * the consumer are "live", so that the consumer can be used to inspect attributes, + * remove attributes, or use any of the other map-provided methods. + * @param attributesConsumer a function that consumes the attributes + * @return this builder + */ + S attributes(Consumer> attributesConsumer); + + /** + * Perform the exchange without a request body. + * @return spec for decoding the response + */ + ResponseSpec exchange(); + } + + /** + * Specification for providing body of a request. + */ + interface RequestBodySpec extends RequestHeadersSpec { + /** + * Set the {@linkplain MediaType media type} of the body, as specified + * by the {@code Content-Type} header. + * @param contentType the content type + * @return the same instance + * @see HttpHeaders#setContentType(MediaType) + */ + RequestBodySpec contentType(MediaType contentType); + + /** + * Set the body to the given {@code Object} value. This method invokes the + * {@link org.springframework.web.client.RestClient.RequestBodySpec#body(Object)} (Object) + * bodyValue} method on the underlying {@code RestClient}. + * @param body the value to write to the request body + * @return spec for further declaration of the request + */ + RequestHeadersSpec body(Object body); + } + + interface Builder { + + /** + * Apply the given {@code Consumer} to this builder instance. + *

This can be useful for applying pre-packaged customizations. + * @param builderConsumer the consumer to apply + */ + Builder apply(Consumer builderConsumer); + + /** + * Add the given cookie to all requests. + * @param cookieName the cookie name + * @param cookieValues the cookie values + */ + Builder defaultCookie(String cookieName, String... cookieValues); + + /** + * Manipulate the default cookies with the given consumer. The + * map provided to the consumer is "live", so that the consumer can be used to + * {@linkplain MultiValueMap#set(Object, Object) overwrite} existing header values, + * {@linkplain MultiValueMap#remove(Object) remove} values, or use any of the other + * {@link MultiValueMap} methods. + * @param cookiesConsumer a function that consumes the cookies map + * @return this builder + */ + Builder defaultCookies(Consumer> cookiesConsumer); + + /** + * Add the given header to all requests that haven't added it. + * @param headerName the header name + * @param headerValues the header values + */ + Builder defaultHeader(String headerName, String... headerValues); + + /** + * Manipulate the default headers with the given consumer. The + * headers provided to the consumer are "live", so that the consumer can be used to + * {@linkplain HttpHeaders#set(String, String) overwrite} existing header values, + * {@linkplain HttpHeaders#remove(String) remove} values, or use any of the other + * {@link HttpHeaders} methods. + * @param headersConsumer a function that consumes the {@code HttpHeaders} + * @return this builder + */ + Builder defaultHeaders(Consumer headersConsumer); + + /** + * Provide a pre-configured {@link UriBuilderFactory} instance as an + * alternative to and effectively overriding {@link #baseUrl(String)}. + */ + Builder uriBuilderFactory(UriBuilderFactory uriFactory); + + /** + * Build the {@link RestTestClient} instance. + */ + RestTestClient build(); + + /** + * Configure a base URI as described in + * {@link RestClient#create(String) + * WebClient.create(String)}. + */ + Builder baseUrl(String baseUrl); + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/RouterFunctionMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/server/RouterFunctionMockMvcSpec.java new file mode 100644 index 000000000000..b1c35e94590a --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/RouterFunctionMockMvcSpec.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2025 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.server; + +import org.jspecify.annotations.Nullable; + +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.web.server.RestTestClient.RouterFunctionSpec; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.RouterFunctionMockMvcBuilder; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Simple wrapper around a {@link RouterFunctionMockMvcBuilder} that implements + * {@link RouterFunctionSpec}. + * + * @author Rob Worsnop + */ +class RouterFunctionMockMvcSpec extends AbstractMockMvcServerSpec + implements RouterFunctionSpec { + + private final RouterFunctionMockMvcBuilder mockMvcBuilder; + + RouterFunctionMockMvcSpec(RouterFunction... routerFunctions) { + this.mockMvcBuilder = MockMvcBuilders.routerFunctions(routerFunctions); + } + + + @Override + public RouterFunctionSpec messageConverters(HttpMessageConverter... messageConverters) { + this.mockMvcBuilder.setMessageConverters(messageConverters); + return this; + } + + @Override + public RouterFunctionSpec interceptors(HandlerInterceptor... interceptors) { + mappedInterceptors(null, interceptors); + return this; + } + + @Override + public RouterFunctionSpec mappedInterceptors(String @Nullable [] pathPatterns, HandlerInterceptor... interceptors) { + this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); + return this; + } + + @Override + public RouterFunctionSpec asyncRequestTimeout(long timeout) { + this.mockMvcBuilder.setAsyncRequestTimeout(timeout); + return this; + } + + @Override + public RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); + return this; + } + + @Override + public RouterFunctionSpec viewResolvers(ViewResolver... resolvers) { + this.mockMvcBuilder.setViewResolvers(resolvers); + return this; + } + + @Override + public RouterFunctionSpec singleView(View view) { + this.mockMvcBuilder.setSingleView(view); + return this; + } + + @Override + public RouterFunctionSpec patternParser(PathPatternParser parser) { + this.mockMvcBuilder.setPatternParser(parser); + return this; + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/StandaloneMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/server/StandaloneMockMvcSpec.java new file mode 100644 index 000000000000..170027c38f97 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/StandaloneMockMvcSpec.java @@ -0,0 +1,170 @@ +/* + * Copyright 2002-2025 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.server; + +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; +import org.springframework.validation.Validator; +import org.springframework.web.accept.ContentNegotiationManager; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.servlet.FlashMapManager; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * Simple wrapper around a {@link StandaloneMockMvcBuilder} that implements + * {@link RestTestClient.ControllerSpec}. + * + * @author Rob Worsnop + */ +class StandaloneMockMvcSpec extends AbstractMockMvcServerSpec + implements RestTestClient.ControllerSpec { + + private final StandaloneMockMvcBuilder mockMvcBuilder; + + StandaloneMockMvcSpec(Object... controllers) { + this.mockMvcBuilder = MockMvcBuilders.standaloneSetup(controllers); + } + + @Override + public StandaloneMockMvcSpec controllerAdvice(Object... controllerAdvice) { + this.mockMvcBuilder.setControllerAdvice(controllerAdvice); + return this; + } + + @Override + public StandaloneMockMvcSpec messageConverters(HttpMessageConverter... messageConverters) { + this.mockMvcBuilder.setMessageConverters(messageConverters); + return this; + } + + @Override + public StandaloneMockMvcSpec validator(Validator validator) { + this.mockMvcBuilder.setValidator(validator); + return this; + } + + @Override + public StandaloneMockMvcSpec conversionService(FormattingConversionService conversionService) { + this.mockMvcBuilder.setConversionService(conversionService); + return this; + } + + @Override + public StandaloneMockMvcSpec interceptors(HandlerInterceptor... interceptors) { + mappedInterceptors(null, interceptors); + return this; + } + + @Override + public StandaloneMockMvcSpec mappedInterceptors( + String @Nullable [] pathPatterns, HandlerInterceptor... interceptors) { + + this.mockMvcBuilder.addMappedInterceptors(pathPatterns, interceptors); + return this; + } + + @Override + public StandaloneMockMvcSpec contentNegotiationManager(ContentNegotiationManager manager) { + this.mockMvcBuilder.setContentNegotiationManager(manager); + return this; + } + + @Override + public StandaloneMockMvcSpec asyncRequestTimeout(long timeout) { + this.mockMvcBuilder.setAsyncRequestTimeout(timeout); + return this; + } + + @Override + public StandaloneMockMvcSpec customArgumentResolvers(HandlerMethodArgumentResolver... argumentResolvers) { + this.mockMvcBuilder.setCustomArgumentResolvers(argumentResolvers); + return this; + } + + @Override + public StandaloneMockMvcSpec customReturnValueHandlers(HandlerMethodReturnValueHandler... handlers) { + this.mockMvcBuilder.setCustomReturnValueHandlers(handlers); + return this; + } + + @Override + public StandaloneMockMvcSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers) { + this.mockMvcBuilder.setHandlerExceptionResolvers(exceptionResolvers); + return this; + } + + @Override + public StandaloneMockMvcSpec viewResolvers(ViewResolver... resolvers) { + this.mockMvcBuilder.setViewResolvers(resolvers); + return this; + } + + @Override + public StandaloneMockMvcSpec singleView(View view) { + this.mockMvcBuilder.setSingleView(view); + return this; + } + + @Override + public StandaloneMockMvcSpec localeResolver(LocaleResolver localeResolver) { + this.mockMvcBuilder.setLocaleResolver(localeResolver); + return this; + } + + @Override + public StandaloneMockMvcSpec flashMapManager(FlashMapManager flashMapManager) { + this.mockMvcBuilder.setFlashMapManager(flashMapManager); + return this; + } + + @Override + public StandaloneMockMvcSpec patternParser(PathPatternParser parser) { + this.mockMvcBuilder.setPatternParser(parser); + return this; + } + + @Override + public StandaloneMockMvcSpec placeholderValue(String name, String value) { + this.mockMvcBuilder.addPlaceholderValue(name, value); + return this; + } + + @Override + public StandaloneMockMvcSpec customHandlerMapping(Supplier factory) { + this.mockMvcBuilder.setCustomHandlerMapping(factory); + return this; + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/StatusAssertions.java b/spring-test/src/main/java/org/springframework/test/web/server/StatusAssertions.java new file mode 100644 index 000000000000..cd037f232d25 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/StatusAssertions.java @@ -0,0 +1,250 @@ +/* + * Copyright 2002-2025 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.server; + +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.test.util.AssertionErrors; +import org.springframework.test.web.server.RestTestClient.ResponseSpec; + +import static org.springframework.test.util.AssertionErrors.assertNotNull; + +/** + * Assertions on the response status. + * + * @author Rob Worsnop + * + * @see ResponseSpec#expectStatus() + */ +public class StatusAssertions { + + private final ExchangeResult exchangeResult; + + private final ResponseSpec responseSpec; + + public StatusAssertions(ExchangeResult exchangeResult, ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + + /** + * Assert the response status as an {@link HttpStatusCode}. + */ + public RestTestClient.ResponseSpec isEqualTo(HttpStatusCode status) { + HttpStatusCode actual = this.exchangeResult.getStatus(); + this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", status, actual)); + return this.responseSpec; + } + + /** + * Assert the response status as an integer. + */ + public RestTestClient.ResponseSpec isEqualTo(int status) { + return isEqualTo(HttpStatusCode.valueOf(status)); + } + + /** + * Assert the response status code is {@code HttpStatus.OK} (200). + */ + public RestTestClient.ResponseSpec isOk() { + return assertStatusAndReturn(HttpStatus.OK); + } + + /** + * Assert the response status code is {@code HttpStatus.CREATED} (201). + */ + public RestTestClient.ResponseSpec isCreated() { + return assertStatusAndReturn(HttpStatus.CREATED); + } + + /** + * Assert the response status code is {@code HttpStatus.ACCEPTED} (202). + */ + public RestTestClient.ResponseSpec isAccepted() { + return assertStatusAndReturn(HttpStatus.ACCEPTED); + } + + /** + * Assert the response status code is {@code HttpStatus.NO_CONTENT} (204). + */ + public RestTestClient.ResponseSpec isNoContent() { + return assertStatusAndReturn(HttpStatus.NO_CONTENT); + } + + /** + * Assert the response status code is {@code HttpStatus.FOUND} (302). + */ + public RestTestClient.ResponseSpec isFound() { + return assertStatusAndReturn(HttpStatus.FOUND); + } + + /** + * Assert the response status code is {@code HttpStatus.SEE_OTHER} (303). + */ + public RestTestClient.ResponseSpec isSeeOther() { + return assertStatusAndReturn(HttpStatus.SEE_OTHER); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_MODIFIED} (304). + */ + public RestTestClient.ResponseSpec isNotModified() { + return assertStatusAndReturn(HttpStatus.NOT_MODIFIED); + } + + /** + * Assert the response status code is {@code HttpStatus.TEMPORARY_REDIRECT} (307). + */ + public RestTestClient.ResponseSpec isTemporaryRedirect() { + return assertStatusAndReturn(HttpStatus.TEMPORARY_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.PERMANENT_REDIRECT} (308). + */ + public RestTestClient.ResponseSpec isPermanentRedirect() { + return assertStatusAndReturn(HttpStatus.PERMANENT_REDIRECT); + } + + /** + * Assert the response status code is {@code HttpStatus.BAD_REQUEST} (400). + */ + public RestTestClient.ResponseSpec isBadRequest() { + return assertStatusAndReturn(HttpStatus.BAD_REQUEST); + } + + /** + * Assert the response status code is {@code HttpStatus.UNAUTHORIZED} (401). + */ + public RestTestClient.ResponseSpec isUnauthorized() { + return assertStatusAndReturn(HttpStatus.UNAUTHORIZED); + } + + /** + * Assert the response status code is {@code HttpStatus.FORBIDDEN} (403). + * @since 5.0.2 + */ + public RestTestClient.ResponseSpec isForbidden() { + return assertStatusAndReturn(HttpStatus.FORBIDDEN); + } + + /** + * Assert the response status code is {@code HttpStatus.NOT_FOUND} (404). + */ + public RestTestClient.ResponseSpec isNotFound() { + return assertStatusAndReturn(HttpStatus.NOT_FOUND); + } + + /** + * Assert the response error message. + */ + public RestTestClient.ResponseSpec reasonEquals(String reason) { + String actual = getReasonPhrase(this.exchangeResult.getStatus()); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Response status reason", reason, actual)); + return this.responseSpec; + } + + private static String getReasonPhrase(HttpStatusCode statusCode) { + if (statusCode instanceof HttpStatus status) { + return status.getReasonPhrase(); + } + else { + return ""; + } + } + + + /** + * Assert the response status code is in the 1xx range. + */ + public RestTestClient.ResponseSpec is1xxInformational() { + return assertSeriesAndReturn(HttpStatus.Series.INFORMATIONAL); + } + + /** + * Assert the response status code is in the 2xx range. + */ + public RestTestClient.ResponseSpec is2xxSuccessful() { + return assertSeriesAndReturn(HttpStatus.Series.SUCCESSFUL); + } + + /** + * Assert the response status code is in the 3xx range. + */ + public RestTestClient.ResponseSpec is3xxRedirection() { + return assertSeriesAndReturn(HttpStatus.Series.REDIRECTION); + } + + /** + * Assert the response status code is in the 4xx range. + */ + public RestTestClient.ResponseSpec is4xxClientError() { + return assertSeriesAndReturn(HttpStatus.Series.CLIENT_ERROR); + } + + /** + * Assert the response status code is in the 5xx range. + */ + public RestTestClient.ResponseSpec is5xxServerError() { + return assertSeriesAndReturn(HttpStatus.Series.SERVER_ERROR); + } + + /** + * Match the response status value with a Hamcrest matcher. + * @param matcher the matcher to use + * @since 5.1 + */ + public RestTestClient.ResponseSpec value(Matcher matcher) { + int actual = this.exchangeResult.getStatus().value(); + this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat("Response status", actual, matcher)); + return this.responseSpec; + } + + /** + * Consume the response status value as an integer. + * @param consumer the consumer to use + * @since 5.1 + */ + public RestTestClient.ResponseSpec value(Consumer consumer) { + int actual = this.exchangeResult.getStatus().value(); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(actual)); + return this.responseSpec; + } + + + private ResponseSpec assertStatusAndReturn(HttpStatus expected) { + assertNotNull("exchangeResult unexpectedly null", this.exchangeResult); + HttpStatusCode actual = this.exchangeResult.getStatus(); + this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.assertEquals("Status", expected, actual)); + return this.responseSpec; + } + + private RestTestClient.ResponseSpec assertSeriesAndReturn(HttpStatus.Series expected) { + HttpStatusCode status = this.exchangeResult.getStatus(); + HttpStatus.Series series = HttpStatus.Series.resolve(status.value()); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.assertEquals("Range for response status value " + status, expected, series)); + return this.responseSpec; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/XpathAssertions.java b/spring-test/src/main/java/org/springframework/test/web/server/XpathAssertions.java new file mode 100644 index 000000000000..5be4a5b85f54 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/XpathAssertions.java @@ -0,0 +1,205 @@ +/* + * Copyright 2002-2025 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.server; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import javax.xml.xpath.XPathExpressionException; + +import org.hamcrest.Matcher; +import org.jspecify.annotations.Nullable; + +import org.springframework.http.HttpHeaders; +import org.springframework.test.util.XpathExpectationsHelper; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * XPath assertions for the {@link RestTestClient}. + * + * @author Rob Worsnop + */ +public class XpathAssertions { + + private final RestTestClient.BodyContentSpec bodySpec; + + private final XpathExpectationsHelper xpathHelper; + + + XpathAssertions(RestTestClient.BodyContentSpec spec, + String expression, @Nullable Map namespaces, Object... args) { + + this.bodySpec = spec; + this.xpathHelper = initXpathHelper(expression, namespaces, args); + } + + private static XpathExpectationsHelper initXpathHelper( + String expression, @Nullable Map namespaces, Object[] args) { + + try { + return new XpathExpectationsHelper(expression, namespaces, args); + } + catch (XPathExpressionException ex) { + throw new AssertionError("XML parsing error", ex); + } + } + + + /** + * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, String)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(String expectedValue) { + return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Double)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(Double expectedValue) { + return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertBoolean(byte[], String, boolean)}. + */ + public RestTestClient.BodyContentSpec isEqualTo(boolean expectedValue) { + return assertWith(() -> this.xpathHelper.assertBoolean(getContent(), getCharset(), expectedValue)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#exists(byte[], String)}. + */ + public RestTestClient.BodyContentSpec exists() { + return assertWith(() -> this.xpathHelper.exists(getContent(), getCharset())); + } + + /** + * Delegates to {@link XpathExpectationsHelper#doesNotExist(byte[], String)}. + */ + public RestTestClient.BodyContentSpec doesNotExist() { + return assertWith(() -> this.xpathHelper.doesNotExist(getContent(), getCharset())); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, int)}. + */ + public RestTestClient.BodyContentSpec nodeCount(int expectedCount) { + return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), expectedCount)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertString(byte[], String, Matcher)}. + */ + public RestTestClient.BodyContentSpec string(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}. + */ + public RestTestClient.BodyContentSpec number(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher)); + } + + /** + * Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}. + */ + public RestTestClient.BodyContentSpec nodeCount(Matcher matcher){ + return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher)); + } + + /** + * Consume the result of the XPath evaluation as a String. + */ + public RestTestClient.BodyContentSpec string(Consumer consumer){ + return assertWith(() -> { + String value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), String.class); + consumer.accept(value); + }); + } + + /** + * Consume the result of the XPath evaluation as a Double. + */ + public RestTestClient.BodyContentSpec number(Consumer consumer){ + return assertWith(() -> { + Double value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Double.class); + consumer.accept(value); + }); + } + + /** + * Consume the count of nodes as result of the XPath evaluation. + */ + public RestTestClient.BodyContentSpec nodeCount(Consumer consumer){ + return assertWith(() -> { + Integer value = this.xpathHelper.evaluateXpath(getContent(), getCharset(), Integer.class); + consumer.accept(value); + }); + } + + private RestTestClient.BodyContentSpec assertWith(CheckedExceptionTask task) { + try { + task.run(); + } + catch (Exception ex) { + throw new AssertionError("XML parsing error", ex); + } + return this.bodySpec; + } + + private byte[] getContent() { + byte[] body = this.bodySpec.returnResult().getResponseBody(); + Assert.notNull(body, "Expected body content"); + return body; + } + + private String getCharset() { + return Optional.of(this.bodySpec.returnResult()) + .map(EntityExchangeResult::getResponseHeaders) + .map(HttpHeaders::getContentType) + .map(MimeType::getCharset) + .orElse(StandardCharsets.UTF_8) + .name(); + } + + + @Override + public boolean equals(@Nullable Object obj) { + throw new AssertionError("Object#equals is disabled " + + "to avoid being used in error instead of XPathAssertions#isEqualTo(String)."); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + + + /** + * Lets us be able to use lambda expressions that could throw checked exceptions, since + * {@link XpathExpectationsHelper} throws {@link Exception} on its methods. + */ + private interface CheckedExceptionTask { + + void run() throws Exception; + + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/server/package-info.java b/spring-test/src/main/java/org/springframework/test/web/server/package-info.java new file mode 100644 index 000000000000..20caac64d575 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/server/package-info.java @@ -0,0 +1,8 @@ +/** + * Support for testing Spring Web server endpoints via + * {@link org.springframework.test.web.server.RestTestClient}. + */ +@NullMarked +package org.springframework.test.web.server; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-test/src/test/java/org/springframework/test/web/server/CookieAssertionsTests.java b/spring-test/src/test/java/org/springframework/test/web/server/CookieAssertionsTests.java new file mode 100644 index 000000000000..a2b817961bd2 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/CookieAssertionsTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2002-2025 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.server; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link CookieAssertions} + * + * @author Rob Worsnop + */ +public class CookieAssertionsTests { + + private final ResponseCookie cookie = ResponseCookie.from("foo", "bar") + .maxAge(Duration.ofMinutes(30)) + .domain("foo.com") + .path("/foo") + .secure(true) + .httpOnly(true) + .partitioned(true) + .sameSite("Lax") + .build(); + + private final CookieAssertions assertions = cookieAssertions(cookie); + + + @Test + void valueEquals() { + assertions.valueEquals("foo", "bar"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.valueEquals("what?!", "bar")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.valueEquals("foo", "what?!")); + } + + @Test + void value() { + assertions.value("foo", equalTo("bar")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value("foo", equalTo("what?!"))); + } + + @Test + void valueConsumer() { + assertions.value("foo", input -> assertThat(input).isEqualTo("bar")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.value("foo", input -> assertThat(input).isEqualTo("what?!"))); + } + + @Test + void exists() { + assertions.exists("foo"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.exists("what?!")); + } + + @Test + void doesNotExist() { + assertions.doesNotExist("what?!"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.doesNotExist("foo")); + } + + @Test + void maxAge() { + assertions.maxAge("foo", Duration.ofMinutes(30)); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.maxAge("foo", Duration.ofMinutes(29))); + + assertions.maxAge("foo", equalTo(Duration.ofMinutes(30).getSeconds())); + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.maxAge("foo", equalTo(Duration.ofMinutes(29).getSeconds()))); + } + + @Test + void domain() { + assertions.domain("foo", "foo.com"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.domain("foo", "what.com")); + + assertions.domain("foo", equalTo("foo.com")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.domain("foo", equalTo("what.com"))); + } + + @Test + void path() { + assertions.path("foo", "/foo"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.path("foo", "/what")); + + assertions.path("foo", equalTo("/foo")); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.path("foo", equalTo("/what"))); + } + + @Test + void secure() { + assertions.secure("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.secure("foo", false)); + } + + @Test + void httpOnly() { + assertions.httpOnly("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.httpOnly("foo", false)); + } + + @Test + void partitioned() { + assertions.partitioned("foo", true); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.partitioned("foo", false)); + } + + @Test + void sameSite() { + assertions.sameSite("foo", "Lax"); + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.sameSite("foo", "Strict")); + } + + + private CookieAssertions cookieAssertions(ResponseCookie cookie) { + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + var headers = new HttpHeaders(); + headers.set(HttpHeaders.SET_COOKIE, cookie.toString()); + when(response.getHeaders()).thenReturn(headers); + ExchangeResult result = new ExchangeResult(response); + return new CookieAssertions(result, mock()); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/server/HeaderAssertionTests.java new file mode 100644 index 000000000000..f1418e7c5ecd --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/HeaderAssertionTests.java @@ -0,0 +1,320 @@ +/* + * Copyright 2002-2025 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.server; + +import java.net.URI; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItems; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link HeaderAssertions}. + * + * @author Rob Worsnop + */ +class HeaderAssertionTests { + + @Test + void valueEquals() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + headers.add("age", "22"); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.valueEquals("foo", "bar"); + assertions.value("foo", s -> assertThat(s).isEqualTo("bar")); + assertions.values("foo", strings -> assertThat(strings).containsExactly("bar")); + assertions.valueEquals("age", 22); + + // Missing header + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("what?!", "bar")); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "what?!")); + + // Wrong # of values + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "bar", "what?!")); + } + + @Test + void valueEqualsWithMultipleValues() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + headers.add("foo", "baz"); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.valueEquals("foo", "bar", "baz"); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "bar", "what?!")); + + // Too few values + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEquals("foo", "bar")); + } + + @Test + void valueMatches() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.valueMatches("Content-Type", ".*UTF-8.*"); + + // Wrong pattern + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueMatches("Content-Type", ".*ISO-8859-1.*")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header " + + "'Content-Type'=[application/json;charset=UTF-8] does not match " + + "[.*ISO-8859-1.*]")); + } + + @Test + void valueMatchesWithNonexistentHeader() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valueMatches("Content-XYZ", ".*ISO-8859-1.*")) + .withMessage("Response header 'Content-XYZ' not found"); + } + + @Test + void valuesMatch() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "value1"); + headers.add("foo", "value2"); + headers.add("foo", "value3"); + HeaderAssertions assertions = headerAssertions(headers); + + assertions.valuesMatch("foo", "val.*1", "val.*2", "val.*3"); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valuesMatch("foo", ".*", "val.*5")) + .satisfies(ex -> assertThat(ex).hasMessage( + "Response header 'foo' has fewer or more values [value1, value2, value3] " + + "than number of patterns to match with [.*, val.*5]")); + + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.valuesMatch("foo", ".*", "val.*5", ".*")) + .satisfies(ex -> assertThat(ex).hasMessage( + "Response header 'foo'[1]='value2' does not match 'val.*5'")); + } + + @Test + void valueMatcher() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + HeaderAssertions assertions = headerAssertions(headers); + + assertions.value("foo", containsString("a")); + } + + @Test + void valuesMatcher() { + HttpHeaders headers = new HttpHeaders(); + headers.add("foo", "bar"); + headers.add("foo", "baz"); + HeaderAssertions assertions = headerAssertions(headers); + + assertions.values("foo", hasItems("bar", "baz")); + } + + @Test + void exists() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.exists("Content-Type"); + + // Header should not exist + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.exists("Framework")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header 'Framework' does not exist")); + } + + @Test + void doesNotExist() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/json;charset=UTF-8")); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.doesNotExist("Framework"); + + // Existing header + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.doesNotExist("Content-Type")) + .satisfies(ex -> assertThat(ex).hasMessage("Response header " + + "'Content-Type' exists with value=[application/json;charset=UTF-8]")); + } + + @Test + void contentTypeCompatibleWith() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_XML); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.contentTypeCompatibleWith(MediaType.parseMediaType("application/*")); + assertions.contentTypeCompatibleWith("application/*"); + + // MediaTypes not compatible + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> assertions.contentTypeCompatibleWith(MediaType.TEXT_XML)) + .withMessage("Response header 'Content-Type'=[application/xml] is not compatible with [text/xml]"); + } + + @Test + void location() { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create("http://localhost:8080/")); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.location("http://localhost:8080/"); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.location("http://localhost:8081/")); + } + + @Test + void cacheControl() { + CacheControl control = CacheControl.maxAge(1, TimeUnit.HOURS).noTransform(); + + HttpHeaders headers = new HttpHeaders(); + headers.setCacheControl(control.getHeaderValue()); + HeaderAssertions assertions = headerAssertions(headers); + + // Success + assertions.cacheControl(control); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.cacheControl(CacheControl.noStore())); + } + + @Test + void contentDisposition() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentDispositionFormData("foo", "bar"); + HeaderAssertions assertions = headerAssertions(headers); + assertions.contentDisposition(ContentDisposition.formData().name("foo").filename("bar").build()); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.contentDisposition(ContentDisposition.attachment().build())); + } + + @Test + void contentLength() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentLength(100); + HeaderAssertions assertions = headerAssertions(headers); + assertions.contentLength(100); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.contentLength(200)); + } + + @Test + void contentType() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HeaderAssertions assertions = headerAssertions(headers); + assertions.contentType(MediaType.APPLICATION_JSON); + assertions.contentType("application/json"); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.contentType(MediaType.APPLICATION_XML)); + } + + + @Test + void expires() { + HttpHeaders headers = new HttpHeaders(); + ZonedDateTime expires = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); + headers.setExpires(expires); + HeaderAssertions assertions = headerAssertions(headers); + assertions.expires(expires.toInstant().toEpochMilli()); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.expires(expires.toInstant().toEpochMilli() + 1)); + } + + @Test + void lastModified() { + HttpHeaders headers = new HttpHeaders(); + ZonedDateTime lastModified = ZonedDateTime.of(2018, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")); + headers.setLastModified(lastModified.toInstant().toEpochMilli()); + HeaderAssertions assertions = headerAssertions(headers); + assertions.lastModified(lastModified.toInstant().toEpochMilli()); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.lastModified(lastModified.toInstant().toEpochMilli() + 1)); + } + + @Test + void equalsDate() { + HttpHeaders headers = new HttpHeaders(); + headers.setDate("foo", 1000); + HeaderAssertions assertions = headerAssertions(headers); + assertions.valueEqualsDate("foo", 1000); + + // Wrong value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.valueEqualsDate("foo", 2000)); + } + + private HeaderAssertions headerAssertions(HttpHeaders responseHeaders) { + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + when(response.getHeaders()).thenReturn(responseHeaders); + ExchangeResult result = new ExchangeResult(response); + return new HeaderAssertions(result, mock()); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/JsonPathAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/server/JsonPathAssertionTests.java new file mode 100644 index 000000000000..37d10ed6126f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/JsonPathAssertionTests.java @@ -0,0 +1,217 @@ +/* + * Copyright 2002-2025 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.server; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.Person; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.in; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests JSON Path assertions with {@link RestTestClient}. + * + * @author Rob Worsnop + */ +class JsonPathAssertionTests { + + private final RestTestClient client = + RestTestClient.bindToController(new MusicController()) + .alwaysExpect(status().isOk()) + .alwaysExpect(content().contentType(MediaType.APPLICATION_JSON)) + .configureClient() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + + + @Test + void exists() { + String composerByName = "$.composers[?(@.name == '%s')]"; + String performerByName = "$.performers[?(@.name == '%s')]"; + + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath(composerByName.formatted("Johann Sebastian Bach")).exists() + .jsonPath(composerByName.formatted("Johannes Brahms")).exists() + .jsonPath(composerByName.formatted("Edvard Grieg")).exists() + .jsonPath(composerByName.formatted("Robert Schumann")).exists() + .jsonPath(performerByName.formatted("Vladimir Ashkenazy")).exists() + .jsonPath(performerByName.formatted("Yehudi Menuhin")).exists() + .jsonPath("$.composers[0]").exists() + .jsonPath("$.composers[1]").exists() + .jsonPath("$.composers[2]").exists() + .jsonPath("$.composers[3]").exists(); + } + + @Test + void doesNotExist() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[?(@.name == 'Edvard Grieeeeeeg')]").doesNotExist() + .jsonPath("$.composers[?(@.name == 'Robert Schuuuuuuman')]").doesNotExist() + .jsonPath("$.composers[4]").doesNotExist(); + } + + @Test + void equality() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].name").isEqualTo("Johann Sebastian Bach") + .jsonPath("$.performers[1].name").isEqualTo("Yehudi Menuhin"); + + // Hamcrest matchers... + client.get().uri("/music/people") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.composers[0].name").value(equalTo("Johann Sebastian Bach")) + .jsonPath("$.performers[1].name").value(equalTo("Yehudi Menuhin")); + } + + @Test + void hamcrestMatcher() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].name").value(startsWith("Johann")) + .jsonPath("$.performers[0].name").value(endsWith("Ashkenazy")) + .jsonPath("$.performers[1].name").value(containsString("di Me")) + .jsonPath("$.composers[1].name").value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms")))); + } + + @Test + void hamcrestMatcherWithParameterizedJsonPath() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].name").value(String.class, startsWith("Johann")) + .jsonPath("$.composers[0].name").value(String.class, s -> assertThat(s).startsWith("Johann")) + .jsonPath("$.composers[0].name").value(o -> assertThat((String) o).startsWith("Johann")) + .jsonPath("$.performers[1].name").value(containsString("di Me")) + .jsonPath("$.composers[1].name").value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms")))); + } + + @Test + void isEmpty() { + client.get().uri("/music/instruments") + .exchange() + .expectBody() + .jsonPath("$.clarinets").isEmpty(); + } + + @Test + void isNotEmpty() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers").isNotEmpty(); + } + + @Test + void hasJsonPath() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers").hasJsonPath(); + } + + @Test + void doesNotHaveJsonPath() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.audience").doesNotHaveJsonPath(); + } + + @Test + void isBoolean() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].someBoolean").isBoolean(); + } + + @Test + void isNumber() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers[0].someDouble").isNumber(); + } + + @Test + void isMap() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$").isMap(); + } + + @Test + void isArray() { + client.get().uri("/music/people") + .exchange() + .expectBody() + .jsonPath("$.composers").isArray(); + } + + @RestController + private static class MusicController { + @GetMapping("/music/instruments") + public Map getInstruments() { + return Map.of("clarinets", List.of()); + } + + @GetMapping("/music/people") + public MultiValueMap get() { + MultiValueMap map = new LinkedMultiValueMap<>(); + + map.add("composers", new Person("Johann Sebastian Bach")); + map.add("composers", new Person("Johannes Brahms")); + map.add("composers", new Person("Edvard Grieg")); + map.add("composers", new Person("Robert Schumann")); + + map.add("performers", new Person("Vladimir Ashkenazy")); + map.add("performers", new Person("Yehudi Menuhin")); + + return map; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/StandaloneMockMvcSpecTests.java b/spring-test/src/test/java/org/springframework/test/web/server/StandaloneMockMvcSpecTests.java new file mode 100644 index 000000000000..b8ebe85b3825 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/StandaloneMockMvcSpecTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2002-2025 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.server; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests for {@link StandaloneMockMvcSpec}. + * + * @author Rob Worsnop + */ +public class StandaloneMockMvcSpecTests { + + @Test + public void controller() { + new StandaloneMockMvcSpec(new MyController()).build() + .get().uri("/") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("Success"); + + new StandaloneMockMvcSpec(new MyController()).build() + .get().uri("") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("Success"); + } + + @Test + public void controllerAdvice() { + new StandaloneMockMvcSpec(new MyController()) + .controllerAdvice(new MyControllerAdvice()) + .build() + .get().uri("/exception") + .exchange() + .expectStatus().isBadRequest() + .expectBody(String.class).isEqualTo("Handled exception"); + } + + @Test + public void controllerAdviceWithClassArgument() { + new StandaloneMockMvcSpec(MyController.class) + .controllerAdvice(MyControllerAdvice.class) + .build() + .get().uri("/exception") + .exchange() + .expectStatus().isBadRequest() + .expectBody(String.class).isEqualTo("Handled exception"); + } + + @SuppressWarnings("unused") + @RestController + private static class MyController { + + @GetMapping + public String handleRootPath() { + return "Success"; + } + + @GetMapping("/exception") + public void handleWithError() { + throw new IllegalStateException(); + } + + } + + + @ControllerAdvice + private static class MyControllerAdvice { + + @ExceptionHandler + public ResponseEntity handle(IllegalStateException ex) { + return ResponseEntity.status(400).body("Handled exception"); + } + } + + + private static class TestConsumer implements Consumer { + + private T value; + + public T getValue() { + return this.value; + } + + @Override + public void accept(T t) { + this.value = t; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/server/StatusAssertionTests.java new file mode 100644 index 000000000000..08fdf9e305e9 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/StatusAssertionTests.java @@ -0,0 +1,266 @@ +/* + * Copyright 2002-2025 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.server; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.when; + +/** + * Tests for {@link StatusAssertions}. + * + * @author Rob Worsnop + */ +class StatusAssertionTests { + + @Test + void isEqualTo() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.isEqualTo(HttpStatus.CONFLICT); + assertions.isEqualTo(409); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.isEqualTo(HttpStatus.REQUEST_TIMEOUT)); + + // Wrong status value + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.isEqualTo(408)); + } + + @Test + void isEqualToWithCustomStatus() { + StatusAssertions assertions = statusAssertions(600); + + // Success + assertions.isEqualTo(600); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(601).isEqualTo(600)); + + } + + @Test + void reasonEquals() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.reasonEquals("Conflict"); + + // Wrong reason + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).reasonEquals("Conflict")); + } + + @Test + void statusSeries1xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONTINUE); + + // Success + assertions.is1xxInformational(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.OK).is1xxInformational()); + } + + @Test + void statusSeries2xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.OK); + + // Success + assertions.is2xxSuccessful(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is2xxSuccessful()); + } + + @Test + void statusSeries3xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.PERMANENT_REDIRECT); + + // Success + assertions.is3xxRedirection(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is3xxRedirection()); + } + + @Test + void statusSeries4xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.BAD_REQUEST); + + // Success + assertions.is4xxClientError(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).is4xxClientError()); + } + + @Test + void statusSeries5xx() { + StatusAssertions assertions = statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR); + + // Success + assertions.is5xxServerError(); + + // Wrong series + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + statusAssertions(HttpStatus.OK).is5xxServerError()); + } + + @Test + void matchesStatusValue() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.value(equalTo(409)); + assertions.value(greaterThan(400)); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + assertions.value(equalTo(200))); + } + + @Test + void matchesCustomStatusValue() { + statusAssertions(600).value(equalTo(600)); + } + + @Test + void consumesStatusValue() { + StatusAssertions assertions = statusAssertions(HttpStatus.CONFLICT); + + // Success + assertions.value((Integer value) -> assertThat(value).isEqualTo(409)); + } + + @Test + void statusIsAccepted() { + // Success + statusAssertions(HttpStatus.ACCEPTED).isAccepted(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isAccepted()); + } + + @Test + void statusIsNoContent() { + // Success + statusAssertions(HttpStatus.NO_CONTENT).isNoContent(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNoContent()); + } + + @Test + void statusIsFound() { + // Success + statusAssertions(HttpStatus.FOUND).isFound(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isFound()); + } + + @Test + void statusIsSeeOther() { + // Success + statusAssertions(HttpStatus.SEE_OTHER).isSeeOther(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isSeeOther()); + } + + @Test + void statusIsNotModified() { + // Success + statusAssertions(HttpStatus.NOT_MODIFIED).isNotModified(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isNotModified()); + } + + @Test + void statusIsTemporaryRedirect() { + // Success + statusAssertions(HttpStatus.TEMPORARY_REDIRECT).isTemporaryRedirect(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isTemporaryRedirect()); + } + + @Test + void statusIsPermanentRedirect() { + // Success + statusAssertions(HttpStatus.PERMANENT_REDIRECT).isPermanentRedirect(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isPermanentRedirect()); + } + + @Test + void statusIsUnauthorized() { + // Success + statusAssertions(HttpStatus.UNAUTHORIZED).isUnauthorized(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isUnauthorized()); + } + + @Test + void statusIsForbidden() { + // Success + statusAssertions(HttpStatus.FORBIDDEN).isForbidden(); + + // Wrong status + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> statusAssertions(HttpStatus.INTERNAL_SERVER_ERROR).isForbidden()); + } + + private StatusAssertions statusAssertions(HttpStatus status) { + return statusAssertions(status.value()); + } + + private StatusAssertions statusAssertions(int status) { + try { + RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse response = mock(); + when(response.getStatusCode()).thenReturn(HttpStatusCode.valueOf(status)); + ExchangeResult result = new ExchangeResult(response); + return new StatusAssertions(result, mock()); + } + catch (IOException ex) { + throw new AssertionError(ex); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/ErrorTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/ErrorTests.java new file mode 100644 index 000000000000..a54a03548d80 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/ErrorTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpStatus; +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests with error status codes or error conditions. + * + * @author Rob Worsnop + */ +class ErrorTests { + + private final RestTestClient client = RestTestClient.bindToController(new TestController()).build(); + + + @Test + void notFound(){ + this.client.get().uri("/invalid") + .exchange() + .expectStatus().isNotFound(); + } + + @Test + void serverException() { + this.client.get().uri("/server-error") + .exchange() + .expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @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/server/samples/HeaderAndCookieTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/HeaderAndCookieTests.java new file mode 100644 index 000000000000..0e8506e5fa50 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/HeaderAndCookieTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** + * Tests with headers and cookies. + * + * @author Rob Worsnop + */ +class HeaderAndCookieTests { + + private final RestTestClient client = RestTestClient.bindToController(new TestController()).build(); + + @Test + void requestResponseHeaderPair() { + this.client.get().uri("/header-echo") + .header("h1", "in") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("h1", "in-out"); + } + + @Test + void headerMultipleValues() { + this.client.get().uri("/header-multi-value") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("h1", "v1", "v2", "v3"); + } + + @Test + void setCookies() { + this.client.get().uri("/cookie-echo") + .cookies(cookies -> cookies.add("k1", "v1")) + .exchange() + .expectHeader().valueMatches("Set-Cookie", "k1=v1"); + } + + @RestController + static class TestController { + + @GetMapping("header-echo") + ResponseEntity handleHeader(@RequestHeader("h1") String myHeader) { + String value = myHeader + "-out"; + return ResponseEntity.ok().header("h1", value).build(); + } + + @GetMapping("header-multi-value") + ResponseEntity multiValue() { + return ResponseEntity.ok().header("h1", "v1", "v2", "v3").build(); + } + + @GetMapping("cookie-echo") + ResponseEntity handleCookie(@CookieValue("k1") String cookieValue) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Set-Cookie", "k1=" + cookieValue); + return new ResponseEntity<>(headers, HttpStatus.OK); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/JsonContentTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/JsonContentTests.java new file mode 100644 index 000000000000..eb737a465ef0 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/JsonContentTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import java.net.URI; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.json.JsonCompareMode; +import org.springframework.test.web.server.RestTestClient; +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.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.hamcrest.Matchers.containsString; + +/** + * Samples of tests using {@link RestTestClient} with serialized JSON content. + * + * @author Rob Worsnop + */ +class JsonContentTests { + + private final RestTestClient client = RestTestClient.bindToController(new PersonController()).build(); + + + @Test + void jsonContentWithDefaultLenientMode() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().json(""" + [ + {"firstName":"Jane"}, + {"firstName":"Jason"}, + {"firstName":"John"} + ] + """); + } + + @Test + void jsonContentWithStrictMode() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().json(""" + [ + {"firstName":"Jane", "lastName":"Williams"}, + {"firstName":"Jason","lastName":"Johnson"}, + {"firstName":"John", "lastName":"Smith"} + ] + """, + JsonCompareMode.STRICT); + } + + @Test + void jsonContentWithStrictModeAndMissingAttributes() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectBody().json(""" + [ + {"firstName":"Jane"}, + {"firstName":"Jason"}, + {"firstName":"John"} + ] + """, + JsonCompareMode.STRICT) + ); + } + + @Test + void jsonPathIsEqualTo() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$[0].firstName").isEqualTo("Jane") + .jsonPath("$[1].firstName").isEqualTo("Jason") + .jsonPath("$[2].firstName").isEqualTo("John"); + } + + @Test + void jsonPathMatches() { + this.client.get().uri("/persons/John/Smith") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.firstName").value(containsString("oh")); + } + + @Test + void postJsonContent() { + this.client.post().uri("/persons") + .contentType(MediaType.APPLICATION_JSON) + .body(""" + {"firstName":"John", "lastName":"Smith"} + """) + .exchange() + .expectStatus().isCreated() + .expectBody().isEmpty(); + } + + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping + List getPersons() { + return List.of(new Person("Jane", "Williams"), new Person("Jason", "Johnson"), new Person("John", "Smith")); + } + + @GetMapping("/{firstName}/{lastName}") + Person getPerson(@PathVariable String firstName, @PathVariable String lastName) { + return new Person(firstName, lastName); + } + + @PostMapping + ResponseEntity savePerson(@RequestBody Person person) { + return ResponseEntity.created(URI.create(String.format("/persons/%s/%s", person.getFirstName(), person.getLastName()))).build(); + } + } + + static class Person { + private String firstName; + private String lastName; + + public Person() { + } + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return this.firstName; + } + + public String getLastName() { + return this.lastName; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/Person.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/Person.java new file mode 100644 index 000000000000..f2dbc3b8e60e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/Person.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.jspecify.annotations.Nullable; + +@XmlRootElement +class Person { + + private String name; + + + // No-arg constructor for XML + public Person() { + } + + @JsonCreator + public Person(@JsonProperty("name") String name) { + this.name = name; + } + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public boolean equals(@Nullable 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/server/samples/ResponseEntityTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/ResponseEntityTests.java new file mode 100644 index 000000000000..48bbff19b02a --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/ResponseEntityTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.server.RestTestClient; +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.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.startsWith; + +/** + * Annotated controllers accepting and returning typed Objects. + * + * @author Rob Worsnop + */ +class ResponseEntityTests { + private final RestTestClient client = RestTestClient.bindToController(new PersonController()) + .configureClient() + .baseUrl("/persons") + .build(); + + @Test + void entity() { + this.client.get().uri("/John") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Person.class).isEqualTo(new Person("John")); + } + + @Test + void entityMatcher() { + this.client.get().uri("/John") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Person.class).value(Person::getName, startsWith("Joh")); + } + + @Test + void entityWithConsumer() { + this.client.get().uri("/John") + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Person.class) + .consumeWith(result -> assertThat(result.getResponseBody()).isEqualTo(new Person("John"))); + } + + @Test + void entityList() { + List expected = List.of( + new Person("Jane"), new Person("Jason"), new Person("John")); + + this.client.get() + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(new ParameterizedTypeReference>() {}).isEqualTo(expected); + } + + @Test + void entityListWithConsumer() { + this.client.get() + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(new ParameterizedTypeReference>() {}) + .value(people -> + assertThat(people).contains(new Person("Jason")) + ); + } + + @Test + void entityMap() { + Map map = new LinkedHashMap<>(); + map.put("Jane", new Person("Jane")); + map.put("Jason", new Person("Jason")); + map.put("John", new Person("John")); + + this.client.get().uri("?map=true") + .exchange() + .expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() {}).isEqualTo(map); + } + + @Test + void postEntity() { + this.client.post() + .contentType(MediaType.APPLICATION_JSON) + .body(new Person("John")) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals("location", "/persons/John") + .expectBody().isEmpty(); + } + + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_JSON_VALUE) + Person getPerson(@PathVariable String name) { + return new Person(name); + } + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + List getPersons() { + return List.of(new Person("Jane"), new Person("Jason"), new Person("John")); + } + + @GetMapping(params = "map", produces = MediaType.APPLICATION_JSON_VALUE) + Map getPersonsAsMap() { + Map map = new LinkedHashMap<>(); + map.put("Jane", new Person("Jane")); + map.put("Jason", new Person("Jason")); + map.put("John", new Person("John")); + return map; + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + ResponseEntity savePerson(@RequestBody Person person) { + return ResponseEntity.created(URI.create("/persons/" + person.getName())).build(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/RestTestClientTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/RestTestClientTests.java new file mode 100644 index 000000000000..2af1a4c0a6b7 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/RestTestClientTests.java @@ -0,0 +1,330 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.DefaultUriBuilderFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests using the {@link RestTestClient} API. + */ +class RestTestClientTests { + + private RestTestClient client; + + @BeforeEach + void setUp() { + this.client = RestTestClient.bindToController(new TestController()).build(); + } + + @Nested + class HttpMethods { + + @ParameterizedTest + @ValueSource(strings = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"}) + void testMethod(String method) { + RestTestClientTests.this.client.method(HttpMethod.valueOf(method)).uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo(method); + } + + @Test + void testGet() { + RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("GET"); + } + + @Test + void testPost() { + RestTestClientTests.this.client.post().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("POST"); + } + + @Test + void testPut() { + RestTestClientTests.this.client.put().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("PUT"); + } + + @Test + void testDelete() { + RestTestClientTests.this.client.delete().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("DELETE"); + } + + @Test + void testPatch() { + RestTestClientTests.this.client.patch().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("PATCH"); + } + + @Test + void testHead() { + RestTestClientTests.this.client.head().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.method").isEqualTo("HEAD"); + } + + @Test + void testOptions() { + RestTestClientTests.this.client.options().uri("/test") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("Allow", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS") + .expectBody().isEmpty(); + } + + } + + @Nested + class Mutation { + + @Test + void test() { + RestTestClientTests.this.client.mutate() + .apply(builder -> builder.defaultHeader("foo", "bar")) + .uriBuilderFactory(new DefaultUriBuilderFactory("/test")) + .defaultCookie("foo", "bar") + .defaultCookies(cookies -> cookies.add("a", "b")) + .defaultHeaders(headers -> headers.set("a", "b")) + .build().get() + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.uri").isEqualTo("/test") + .jsonPath("$.headers.Cookie").isEqualTo("foo=bar; a=b") + .jsonPath("$.headers.foo").isEqualTo("bar") + .jsonPath("$.headers.a").isEqualTo("b"); + } + } + + @Nested + class Uris { + + @Test + void test() { + RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test"); + } + + @Test + void testWithPathVariables() { + RestTestClientTests.this.client.get().uri("/test/{id}", 1) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test/1"); + } + + @Test + void testWithParameterMap() { + RestTestClientTests.this.client.get().uri("/test/{id}", Map.of("id", 1)) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test/1"); + } + + @Test + void testWithUrlBuilder() { + RestTestClientTests.this.client.get().uri(builder -> builder.path("/test/{id}").build(1)) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test/1"); + } + + @Test + void testURI() { + RestTestClientTests.this.client.get().uri(URI.create("/test")) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.uri").isEqualTo("/test"); + } + } + + @Nested + class Cookies { + @Test + void testCookie() { + RestTestClientTests.this.client.get().uri("/test") + .cookie("foo", "bar") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Cookie").isEqualTo("foo=bar"); + } + + @Test + void testCookies() { + RestTestClientTests.this.client.get().uri("/test") + .cookies(cookies -> cookies.add("foo", "bar")) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Cookie").isEqualTo("foo=bar"); + } + } + + @Nested + class Headers { + @Test + void testHeader() { + RestTestClientTests.this.client.get().uri("/test") + .header("foo", "bar") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.foo").isEqualTo("bar"); + } + + @Test + void testHeaders() { + RestTestClientTests.this.client.get().uri("/test") + .headers(headers -> headers.set("foo", "bar")) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.foo").isEqualTo("bar"); + } + + @Test + void testContentType() { + RestTestClientTests.this.client.post().uri("/test") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Content-Type").isEqualTo("application/json"); + } + + @Test + void testAcceptCharset() { + RestTestClientTests.this.client.get().uri("/test") + .acceptCharset(StandardCharsets.UTF_8) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.Accept-Charset").isEqualTo("utf-8"); + } + + @Test + void testIfModifiedSince() { + RestTestClientTests.this.client.get().uri("/test") + .ifModifiedSince(ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 0, ZoneId.of("GMT"))) + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.If-Modified-Since").isEqualTo("Thu, 01 Jan 1970 00:00:00 GMT"); + } + + @Test + void testIfNoneMatch() { + RestTestClientTests.this.client.get().uri("/test") + .ifNoneMatch("foo") + .exchange() + .expectStatus().isOk() + .expectBody().jsonPath("$.headers.If-None-Match").isEqualTo("foo"); + } + } + + @Nested + class Expectations { + @Test + void testExpectCookie() { + RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectCookie().value("session", Matchers.equalTo("abc")); + } + } + + @Nested + class ReturnResults { + @Test + void testBodyReturnResult() { + var result = RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(Map.class).returnResult(); + assertThat(result.getBody().get("uri")).isEqualTo("/test"); + } + + @Test + void testReturnResultClass() { + var result = RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .returnResult(Map.class); + assertThat(result.getResponseBody().get("uri")).isEqualTo("/test"); + } + + @Test + void testReturnResultParameterizedTypeReference() { + var result = RestTestClientTests.this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .returnResult(new ParameterizedTypeReference>() { + }); + assertThat(result.getResponseBody().get("uri")).isEqualTo("/test"); + } + } + + @RestController + static class TestController { + + @RequestMapping(path = {"/test", "/test/*"}, produces = "application/json") + public Map handle( + @RequestHeader HttpHeaders headers, + HttpServletRequest request, HttpServletResponse response) { + response.addCookie(new Cookie("session", "abc")); + return Map.of( + "method", request.getMethod(), + "uri", request.getRequestURI(), + "headers", headers.toSingleValueMap() + ); + } + } +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/SoftAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/SoftAssertionTests.java new file mode 100644 index 000000000000..230d88cfc6b3 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/SoftAssertionTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Integration tests for {@link RestTestClient} with soft assertions. + * + */ +class SoftAssertionTests { + + private final RestTestClient restTestClient = RestTestClient.bindToController(new TestController()).build(); + + + @Test + void expectAll() { + this.restTestClient.get().uri("/test").exchange() + .expectAll( + responseSpec -> responseSpec.expectStatus().isOk(), + responseSpec -> responseSpec.expectBody(String.class).isEqualTo("hello") + ); + } + + @Test + void expectAllWithMultipleFailures() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> + this.restTestClient.get().uri("/test").exchange() + .expectAll( + responseSpec -> responseSpec.expectStatus().isBadRequest(), + responseSpec -> responseSpec.expectStatus().isOk(), + responseSpec -> responseSpec.expectBody(String.class).isEqualTo("bogus") + ) + ) + .withMessage(""" + Multiple Exceptions (2): + Status expected:<400 BAD_REQUEST> but was:<200 OK> + Response body expected: but was:"""); + } + + + @RestController + static class TestController { + + @GetMapping("/test") + String handle() { + return "hello"; + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/XmlContentTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/XmlContentTests.java new file mode 100644 index 000000000000..acb448622729 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/XmlContentTests.java @@ -0,0 +1,195 @@ +/* + * Copyright 2002-2025 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.server.samples; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.server.RestTestClient; +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.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; + +/** + * Samples of tests using {@link RestTestClient} with XML content. + * + * @author Rob Worsnop + */ +class XmlContentTests { + + private static final String persons_XML = + "" + + "" + + "Jane" + + "Jason" + + "John" + + ""; + + + private final RestTestClient client = RestTestClient.bindToController(new PersonController()).build(); + + + @Test + void xmlContent() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody().xml(persons_XML); + } + + @Test + void xpathIsEqualTo() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/").exists() + .xpath("/persons").exists() + .xpath("/persons/person").exists() + .xpath("/persons/person").nodeCount(3) + .xpath("/persons/person[1]/name").isEqualTo("Jane") + .xpath("/persons/person[2]/name").isEqualTo("Jason") + .xpath("/persons/person[3]/name").isEqualTo("John"); + } + + @Test + void xpathDoesNotExist() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/persons/person[4]").doesNotExist(); + } + + @Test + void xpathNodeCount() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("/persons/person").nodeCount(3) + .xpath("/persons/person").nodeCount(equalTo(3)); + } + + @Test + void xpathMatches() { + this.client.get().uri("/persons") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("//person/name").string(startsWith("J")) + .xpath("//person/name").string(s -> { + if (!s.startsWith("J")) { + throw new AssertionError("Name does not start with J: " + s); + } + }); + } + + @Test + void xpathContainsSubstringViaRegex() { + this.client.get().uri("/persons/John") + .accept(MediaType.APPLICATION_XML) + .exchange() + .expectStatus().isOk() + .expectBody() + .xpath("//name[contains(text(), 'oh')]").exists(); + } + + @Test + void postXmlContent() { + String content = + "" + + "John"; + + this.client.post().uri("/persons") + .contentType(MediaType.APPLICATION_XML) + .body(content) + .exchange() + .expectStatus().isCreated() + .expectHeader().valueEquals(HttpHeaders.LOCATION, "/persons/John") + .expectBody().isEmpty(); + } + + + @SuppressWarnings("unused") + @XmlRootElement(name="persons") + @XmlAccessorType(XmlAccessType.FIELD) + private static class PersonsWrapper { + + @XmlElement(name="person") + private final List persons = new ArrayList<>(); + + public PersonsWrapper() { + } + + public PersonsWrapper(List persons) { + this.persons.addAll(persons); + } + + public PersonsWrapper(Person... persons) { + this.persons.addAll(Arrays.asList(persons)); + } + + public List getpersons() { + return this.persons; + } + } + + @RestController + @RequestMapping("/persons") + static class PersonController { + + @GetMapping(produces = MediaType.APPLICATION_XML_VALUE) + PersonsWrapper getPersons() { + return new PersonsWrapper(new Person("Jane"), new Person("Jason"), new Person("John")); + } + + @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_XML_VALUE) + Person getPerson(@PathVariable String name) { + return new Person(name); + } + + @PostMapping(consumes = MediaType.APPLICATION_XML_VALUE) + ResponseEntity savepersons(@RequestBody Person person) { + URI location = URI.create(String.format("/persons/%s", person.getName())); + return ResponseEntity.created(location).build(); + } + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/ApplicationContextTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/ApplicationContextTests.java new file mode 100644 index 000000000000..a661e1ae8464 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/ApplicationContextTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2025 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.server.samples.bind; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig; +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +/** + * Sample tests demonstrating "mock" server tests binding to server infrastructure + * declared in a Spring ApplicationContext. + * + * @author Rob Worsnop + */ +@SpringJUnitWebConfig(ApplicationContextTests.WebConfig.class) +class ApplicationContextTests { + + private RestTestClient client; + private final WebApplicationContext context; + + public ApplicationContextTests(WebApplicationContext context) { + this.context = context; + } + + @BeforeEach + void setUp() { + this.client = RestTestClient.bindToApplicationContext(context).build(); + } + + @Test + void test() { + this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + + + @Configuration + 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/server/samples/bind/ControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/ControllerTests.java new file mode 100644 index 000000000000..24f8da3c2706 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/ControllerTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2002-2025 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.server.samples.bind; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Sample tests demonstrating "mock" server tests binding to an annotated + * controller. + * + * @author Rob Worsnop + */ +class ControllerTests { + + private RestTestClient client; + + + @BeforeEach + void setUp() { + this.client = RestTestClient.bindToController(new TestController()).build(); + } + + + @Test + void test() { + this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(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/server/samples/bind/FilterTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/FilterTests.java new file mode 100644 index 000000000000..33f975e6c03f --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/FilterTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2025 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.server.samples.bind; + +import java.io.IOException; +import java.util.Optional; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.http.HttpStatus.I_AM_A_TEAPOT; + + +/** + * Tests for a {@link Filter}. + * @author Rob Worsnop + */ +class FilterTests { + + @Test + void filter() { + + Filter filter = new HttpFilter() { + @Override + protected void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException { + res.getWriter().write("It works!"); + } + }; + + RestTestClient client = RestTestClient.bindToRouterFunction( + request -> Optional.of(req -> ServerResponse.status(I_AM_A_TEAPOT).build())) + .filter(filter) + .build(); + + client.get().uri("/") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/HttpServerTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/HttpServerTests.java new file mode 100644 index 000000000000..f03a85e5206e --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/HttpServerTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2025 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.server.samples.bind; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.testfixture.http.server.reactive.bootstrap.ReactorHttpServer; + +import static org.springframework.web.reactive.function.server.RequestPredicates.GET; +import static org.springframework.web.reactive.function.server.RouterFunctions.route; + +/** + * Sample tests demonstrating live server integration tests. + * + * @author Rob Worsnop + */ +class HttpServerTests { + + private ReactorHttpServer server; + + private RestTestClient client; + + + @BeforeEach + void start() throws Exception { + HttpHandler httpHandler = RouterFunctions.toHttpHandler( + route(GET("/test"), request -> ServerResponse.ok().bodyValue("It works!"))); + + this.server = new ReactorHttpServer(); + this.server.setHandler(httpHandler); + this.server.afterPropertiesSet(); + this.server.start(); + + this.client = RestTestClient.bindToServer() + .baseUrl("http://localhost:" + this.server.getPort()) + .build(); + } + + @AfterEach + void stop() { + this.server.stop(); + } + + + @Test + void test() { + this.client.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + +} diff --git a/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/RouterFunctionTests.java b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/RouterFunctionTests.java new file mode 100644 index 000000000000..65e2513c8c51 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/web/server/samples/bind/RouterFunctionTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2002-2025 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.server.samples.bind; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.test.web.server.RestTestClient; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.springframework.web.servlet.function.RequestPredicates.GET; +import static org.springframework.web.servlet.function.RouterFunctions.route; + +/** + * Sample tests demonstrating "mock" server tests binding to a RouterFunction. + * + * @author Rob Worsnop + */ +class RouterFunctionTests { + + private RestTestClient testClient; + + + @BeforeEach + void setUp() throws Exception { + + RouterFunction route = route(GET("/test"), request -> + ServerResponse.ok().body("It works!")); + + this.testClient = RestTestClient.bindToRouterFunction(route).build(); + } + + @Test + void test() throws Exception { + this.testClient.get().uri("/test") + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo("It works!"); + } + +} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index a95eb7e69284..a7033c933911 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -93,13 +93,14 @@ + - +