This commit is contained in:
Rob Worsnop 2025-07-01 21:25:40 +05:00 committed by GitHub
commit bf197e8f9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 6281 additions and 2 deletions

View File

@ -352,6 +352,7 @@
*** xref:testing/testcontext-framework/support-classes.adoc[]
*** xref:testing/testcontext-framework/aot.adoc[]
** xref:testing/webtestclient.adoc[]
** xref:testing/resttestclient.adoc[]
** xref:testing/mockmvc.adoc[]
*** xref:testing/mockmvc/overview.adoc[]
*** xref:testing/mockmvc/setup-options.adoc[]

View File

@ -0,0 +1,440 @@
[[resttestclient]]
= RestTestClient
`RestTestClient` is an HTTP client designed for testing server applications. It wraps
Spring's xref:integration/rest-clients.adoc#rest-restclient[`RestClient`] and uses it to perform requests
but exposes a testing facade for verifying responses. `RestTestClient` can be used to
perform end-to-end HTTP tests. It can also be used to test Spring MVC
applications without a running server via mock server request and response objects.
[[resttestclient-setup]]
== Setup
To set up a `RestTestClient` you need to choose a server setup to bind to. This can be one
of several mock server setup choices or a connection to a live server.
[[resttestclient-controller-config]]
=== Bind to Controller
This setup allows you to test specific controller(s) via mock request and response objects,
without a running server.
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
RestTestClient client =
RestTestClient.bindToController(new TestController()).build();
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
val client = RestTestClient.bindToController(TestController()).build()
----
======
[[resttestclient-context-config]]
=== Bind to `ApplicationContext`
This setup allows you to load Spring configuration with Spring MVC
infrastructure and controller declarations and use it to handle requests via mock request
and response objects, without a running server.
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
@SpringJUnitConfig(WebConfig.class) // <1>
class MyTests {
RestTestClient client;
@BeforeEach
void setUp(ApplicationContext context) { // <2>
client = RestTestClient.bindToApplicationContext(context).build(); // <3>
}
}
----
<1> Specify the configuration to load
<2> Inject the configuration
<3> Create the `RestTestClient`
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
@SpringJUnitConfig(WebConfig::class) // <1>
class MyTests {
lateinit var client: RestTestClient
@BeforeEach
fun setUp(context: ApplicationContext) { // <2>
client = RestTestClient.bindToApplicationContext(context).build() // <3>
}
}
----
<1> Specify the configuration to load
<2> Inject the configuration
<3> Create the `RestTestClient`
======
[[resttestclient-fn-config]]
=== Bind to Router Function
This setup allows you to test xref:web/webmvc-functional.adoc[functional endpoints] via
mock request and response objects, without a running server.
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
RouterFunction<?> route = ...
client = RestTestClient.bindToRouterFunction(route).build();
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
val route: RouterFunction<*> = ...
val client = RestTestClient.bindToRouterFunction(route).build()
----
======
[[resttestclient-server-config]]
=== Bind to Server
This setup connects to a running server to perform full, end-to-end HTTP tests:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client = RestTestClient.bindToServer().baseUrl("http://localhost:8080").build();
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client = RestTestClient.bindToServer().baseUrl("http://localhost:8080").build()
----
======
[[resttestclient-client-config]]
=== Client Config
In addition to the server setup options described earlier, you can also configure client
options, including base URL, default headers, client filters, and others. These options
are readily available following `bindToServer()`. For all other configuration options,
you need to use `configureClient()` to transition from server to client configuration, as
follows:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client = RestTestClient.bindToController(new TestController())
.configureClient()
.baseUrl("/test")
.build();
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client = RestTestClient.bindToController(TestController())
.configureClient()
.baseUrl("/test")
.build()
----
======
[[resttestclient-tests]]
== Writing Tests
`RestTestClient` provides an API identical to xref:integration/rest-clients.adoc#rest-restclient[`RestClient`]
up to the point of performing a request by using `exchange()`.
After the call to `exchange()`, `RestTestClient` diverges from the `RestClient` and
instead continues with a workflow to verify responses.
To assert the response status and headers, use the following:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON);
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
----
======
If you would like for all expectations to be asserted even if one of them fails, you can
use `expectAll(..)` instead of multiple chained `expect*(..)` calls. This feature is
similar to the _soft assertions_ support in AssertJ and the `assertAll()` support in
JUnit Jupiter.
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectAll(
spec -> spec.expectStatus().isOk(),
spec -> spec.expectHeader().contentType(MediaType.APPLICATION_JSON)
);
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectAll(
{ spec -> spec.expectStatus().isOk() },
{ spec -> spec.expectHeader().contentType(MediaType.APPLICATION_JSON) }
)
----
======
You can then choose to decode the response body through one of the following:
* `expectBody(Class<T>)`: Decode to single object.
* `expectBody()`: Decode to `byte[]` for xref:testing/resttestclient.adoc#resttestclient-json[JSON Content] or an empty body.
If the built-in assertions are insufficient, you can consume the object instead and
perform any other assertions:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.exchange()
.expectStatus().isOk()
.expectBody(Person.class)
.consumeWith(result -> {
// custom assertions (for example, AssertJ)...
});
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.exchange()
.expectStatus().isOk()
.expectBody<Person>()
.consumeWith {
// custom assertions (for example, AssertJ)...
}
----
======
Or you can exit the workflow and obtain a `EntityExchangeResult`:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
EntityExchangeResult<Person> result = client.get().uri("/persons/1")
.exchange()
.expectStatus().isOk()
.expectBody(Person.class)
.returnResult();
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
val result = client.get().uri("/persons/1")
.exchange()
.expectStatus().isOk
.expectBody<Person>()
.returnResult()
----
======
TIP: When you need to decode to a target type with generics, look for the overloaded methods
that accept
{spring-framework-api}/core/ParameterizedTypeReference.html[`ParameterizedTypeReference`]
instead of `Class<T>`.
[[resttestclient-no-content]]
=== No Content
If the response is not expected to have content, you can assert that as follows:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.post().uri("/persons")
.body(person)
.exchange()
.expectStatus().isCreated()
.expectBody().isEmpty();
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.post().uri("/persons")
.body(person)
.exchange()
.expectStatus().isCreated()
.expectBody().isEmpty()
----
======
If you want to ignore the response content, the following releases the content without
any assertions:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/123")
.exchange()
.expectStatus().isNotFound()
.expectBody(Void.class);
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/123")
.exchange()
.expectStatus().isNotFound
.expectBody<Unit>()
----
======
[[resttestclient-json]]
=== JSON Content
You can use `expectBody()` without a target type to perform assertions on the raw
content rather than through higher level Object(s).
To verify the full JSON content with https://jsonassert.skyscreamer.org[JSONAssert]:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.json("{\"name\":\"Jane\"}")
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.json("{\"name\":\"Jane\"}")
----
======
To verify JSON content with https://github.com/jayway/JsonPath[JSONPath]:
[tabs]
======
Java::
+
[source,java,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$[0].name").isEqualTo("Jane")
.jsonPath("$[1].name").isEqualTo("Jason");
----
Kotlin::
+
[source,kotlin,indent=0,subs="verbatim,quotes"]
----
client.get().uri("/persons")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$[0].name").isEqualTo("Jane")
.jsonPath("$[1].name").isEqualTo("Jason")
----
======

View File

@ -20,6 +20,9 @@ import java.net.URI;
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.MockClientHttpRequest;
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 @@ public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory
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 @@ public class MockMvcClientHttpRequestFactory implements ClientHttpRequestFactory
}
}
private static Cookie[] parseCookies(@Nullable List<String> 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()) {

View File

@ -0,0 +1,105 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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 <B> the type of the concrete subclass spec
*/
abstract class AbstractMockMvcServerSpec<B extends RestTestClient.MockMvcServerSpec<B>>
implements RestTestClient.MockMvcServerSpec<B> {
@Override
public <T extends B> T filters(Filter... filters) {
getMockMvcBuilder().addFilters(filters);
return self();
}
@Override
public final <T extends B> T filter(Filter filter, String... urlPatterns) {
getMockMvcBuilder().addFilter(filter, urlPatterns);
return self();
}
@Override
public <T extends B> T defaultRequest(RequestBuilder requestBuilder) {
getMockMvcBuilder().defaultRequest(requestBuilder);
return self();
}
@Override
public <T extends B> T alwaysExpect(ResultMatcher resultMatcher) {
getMockMvcBuilder().alwaysExpect(resultMatcher);
return self();
}
@Override
public <T extends B> T dispatchOptions(boolean dispatchOptions) {
getMockMvcBuilder().dispatchOptions(dispatchOptions);
return self();
}
@Override
public <T extends B> T dispatcherServletCustomizer(DispatcherServletCustomizer customizer) {
getMockMvcBuilder().addDispatcherServletCustomizer(customizer);
return self();
}
@Override
public <T extends B> T apply(MockMvcConfigurer configurer) {
getMockMvcBuilder().apply(configurer);
return self();
}
@SuppressWarnings("unchecked")
private <T extends B> 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();
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<ApplicationContextMockMvcSpec> {
private final DefaultMockMvcBuilder mockMvcBuilder;
public ApplicationContextMockMvcSpec(WebApplicationContext context) {
this.mockMvcBuilder = MockMvcBuilders.webAppContextSetup(context);
}
@Override
protected ConfigurableMockMvcBuilder<?> getMockMvcBuilder() {
return this.mockMvcBuilder;
}
}

View File

@ -0,0 +1,236 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<? super String> 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<String> 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<? super Long> 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<? super String> 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<? super String> 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 + "'";
}
}

View File

@ -0,0 +1,429 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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.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<String, ?> uriVariables) {
this.requestBodySpec = this.requestHeadersUriSpec.uri(uri, uriVariables);
return this;
}
@Override
public RequestBodySpec uri(Function<UriBuilder, URI> 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<MultiValueMap<String, String>> 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<HttpHeaders> 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<Map<String, Object>> 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 <B> BodySpec<B, ?> expectBody(Class<B> bodyType) {
B body = this.exchangeResult.getBody(bodyType);
return new DefaultBodySpec<>(new EntityExchangeResult<>(this.exchangeResult, body));
}
@Override
public <B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> 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 <T> EntityExchangeResult<T> returnResult(Class<T> elementClass) {
return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementClass));
}
@Override
public <T> EntityExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef) {
return new EntityExchangeResult<>(this.exchangeResult, this.exchangeResult.getBody(elementTypeRef));
}
}
private static class DefaultBodyContentSpec implements BodyContentSpec {
private final EntityExchangeResult<byte[]> result;
public DefaultBodyContentSpec(EntityExchangeResult<byte[]> result) {
this.result = result;
}
@Override
public EntityExchangeResult<Void> 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<String, String> 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<byte[]> returnResult() {
return this.result;
}
}
private static class DefaultBodySpec<B, S extends BodySpec<B, S>> implements BodySpec<B, S> {
private final EntityExchangeResult<B> result;
public DefaultBodySpec(@Nullable EntityExchangeResult<B> result) {
this.result = Objects.requireNonNull(result, "exchangeResult must be non-null");
}
@Override
public EntityExchangeResult<B> returnResult() {
return this.result;
}
@Override
public <T extends S> 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 extends S, R> T value(Function<B, R> bodyMapper, Matcher<? super R> matcher) {
this.result.assertWithDiagnostics(() -> {
B body = this.result.getResponseBody();
MatcherAssert.assertThat(bodyMapper.apply(body), matcher);
});
return self();
}
@Override
public <T extends S> T value(Consumer<B> consumer) {
this.result.assertWithDiagnostics(() -> consumer.accept(this.result.getResponseBody()));
return self();
}
@Override
public <T extends S> T consumeWith(Consumer<EntityExchangeResult<B>> consumer) {
this.result.assertWithDiagnostics(() -> consumer.accept(this.result));
return self();
}
@SuppressWarnings("unchecked")
private <T extends S> T self() {
return (T) this;
}
}
}

View File

@ -0,0 +1,89 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<RestTestClient.Builder> 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<MultiValueMap<String, String>> 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<HttpHeaders> 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);
}
}

View File

@ -0,0 +1,46 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.server;
import org.jspecify.annotations.Nullable;
/**
* {@code ExchangeResult} sub-class that exposes the response body fully
* extracted to a representation of type {@code <T>}.
*
* @author Rob Worsnop
* @param <T> the response body type
*/
public class EntityExchangeResult<T> 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;
}
}

View File

@ -0,0 +1,135 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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> T getBody(Class<T> bodyType) {
return this.clientResponse.bodyTo(bodyType);
}
@Nullable
public <T> T getBody(ParameterizedTypeReference<T> 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<String, ResponseCookie> 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();
}
}

View File

@ -0,0 +1,311 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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.
* <p>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<String> 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<? super String> 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<? super Iterable<String>> matcher) {
List<String> 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<String> 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<List<String>> consumer) {
List<String> 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<String> getRequiredValues(String name) {
List<String> 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 + "'";
}
}

View File

@ -0,0 +1,205 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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;
/**
* <a href="https://github.com/jayway/JsonPath">JsonPath</a> assertions.
*
* @author Rob Worsnop
*
* @see <a href="https://github.com/jayway/JsonPath">https://github.com/jayway/JsonPath</a>
* @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 <T> RestTestClient.BodyContentSpec value(Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, Class)}.
*/
public <T> RestTestClient.BodyContentSpec value(Class<T> targetType, Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher, targetType);
return this.bodySpec;
}
/**
* Delegates to {@link JsonPathExpectationsHelper#assertValue(String, Matcher, ParameterizedTypeReference)}.
*/
public <T> RestTestClient.BodyContentSpec value(ParameterizedTypeReference<T> targetType, Matcher<? super T> matcher) {
this.pathHelper.assertValue(this.content, matcher, targetType);
return this.bodySpec;
}
/**
* Consume the result of the JSONPath evaluation.
*/
@SuppressWarnings("unchecked")
public <T> RestTestClient.BodyContentSpec value(Consumer<T> 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 <T> RestTestClient.BodyContentSpec value(Class<T> targetType, Consumer<T> 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 <T> RestTestClient.BodyContentSpec value(ParameterizedTypeReference<T> targetType, Consumer<T> 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();
}
}

View File

@ -0,0 +1,945 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.server;
import java.net.URI;
import java.nio.charset.Charset;
import java.time.ZonedDateTime;
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.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.
* <p>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.
* <p>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.
* <p>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.
* <p><pre class="code">
* RestTestClient client = RestTestClient.bindToServer()
* .baseUrl("http://localhost:8080")
* .build();
* </pre>
* @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<ControllerSpec> {
/**
* Register {@link org.springframework.web.bind.annotation.ControllerAdvice}
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setControllerAdvice(Object...)}.
*/
ControllerSpec controllerAdvice(Object... controllerAdvice);
/**
* Set the message converters to use.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setMessageConverters(HttpMessageConverter[])}.
*/
ControllerSpec messageConverters(HttpMessageConverter<?>... messageConverters);
/**
* Provide a custom {@link Validator}.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setValidator(Validator)}.
*/
ControllerSpec validator(Validator validator);
/**
* Provide a conversion service.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setConversionService(FormattingConversionService)}.
*/
ControllerSpec conversionService(FormattingConversionService conversionService);
/**
* Add global interceptors.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#addInterceptors(HandlerInterceptor...)}.
*/
ControllerSpec interceptors(HandlerInterceptor... interceptors);
/**
* Add interceptors for specific patterns.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#addMappedInterceptors(String[], HandlerInterceptor...)}.
*/
ControllerSpec mappedInterceptors(
String @Nullable [] pathPatterns, HandlerInterceptor... interceptors);
/**
* Set a ContentNegotiationManager.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setContentNegotiationManager(ContentNegotiationManager)}.
*/
ControllerSpec contentNegotiationManager(ContentNegotiationManager manager);
/**
* Specify the timeout value for async execution.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setAsyncRequestTimeout(long)}.
*/
ControllerSpec asyncRequestTimeout(long timeout);
/**
* Provide custom argument resolvers.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setCustomArgumentResolvers(HandlerMethodArgumentResolver...)}.
*/
ControllerSpec customArgumentResolvers(HandlerMethodArgumentResolver... argumentResolvers);
/**
* Provide custom return value handlers.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setCustomReturnValueHandlers(HandlerMethodReturnValueHandler...)}.
*/
ControllerSpec customReturnValueHandlers(HandlerMethodReturnValueHandler... handlers);
/**
* Set the HandlerExceptionResolver types to use.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setHandlerExceptionResolvers(HandlerExceptionResolver...)}.
*/
ControllerSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers);
/**
* Set up view resolution.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setViewResolvers(ViewResolver...)}.
*/
ControllerSpec viewResolvers(ViewResolver... resolvers);
/**
* Set up a single {@link ViewResolver} with a fixed view.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setSingleView(View)}.
*/
ControllerSpec singleView(View view);
/**
* Provide the LocaleResolver to use.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setLocaleResolver(LocaleResolver)}.
*/
ControllerSpec localeResolver(LocaleResolver localeResolver);
/**
* Provide a custom FlashMapManager.
* <p>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}.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setPatternParser(PathPatternParser)}.
*/
ControllerSpec patternParser(PathPatternParser parser);
/**
* Configure placeholder values to use.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#addPlaceholderValue(String, String)}.
*/
ControllerSpec placeholderValue(String name, String value);
/**
* Configure factory for a custom {@link RequestMappingHandlerMapping}.
* <p>This is delegated to
* {@link StandaloneMockMvcBuilder#setCustomHandlerMapping(Supplier)}.
*/
ControllerSpec customHandlerMapping(Supplier<RequestMappingHandlerMapping> 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<RouterFunctionSpec> {
/**
* Set the message converters to use.
* <p>This is delegated to
* {@link RouterFunctionMockMvcBuilder#setMessageConverters(HttpMessageConverter[])}.
*/
RouterFunctionSpec messageConverters(HttpMessageConverter<?>... messageConverters);
/**
* Add global interceptors.
* <p>This is delegated to
* {@link RouterFunctionMockMvcBuilder#addInterceptors(HandlerInterceptor...)}.
*/
RouterFunctionSpec interceptors(HandlerInterceptor... interceptors);
/**
* Add interceptors for specific patterns.
* <p>This is delegated to
* {@link RouterFunctionMockMvcBuilder#addMappedInterceptors(String[], HandlerInterceptor...)}.
*/
RouterFunctionSpec mappedInterceptors(
String @Nullable [] pathPatterns, HandlerInterceptor... interceptors);
/**
* Specify the timeout value for async execution.
* <p>This is delegated to
* {@link RouterFunctionMockMvcBuilder#setAsyncRequestTimeout(long)}.
*/
RouterFunctionSpec asyncRequestTimeout(long timeout);
/**
* Set the HandlerExceptionResolver types to use.
* <p>This is delegated to
* {@link RouterFunctionMockMvcBuilder#setHandlerExceptionResolvers(HandlerExceptionResolver...)}.
*/
RouterFunctionSpec handlerExceptionResolvers(HandlerExceptionResolver... exceptionResolvers);
/**
* Set up view resolution.
* <p>This is delegated to
* {@link RouterFunctionMockMvcBuilder#setViewResolvers(ViewResolver...)}.
*/
RouterFunctionSpec viewResolvers(ViewResolver... resolvers);
/**
* Set up a single {@link ViewResolver} with a fixed view.
* <p>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}.
* <p>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 <B> a self reference to the builder type
*/
interface MockMvcServerSpec<B extends MockMvcServerSpec<B>> {
/**
* Add a global filter.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#addFilters(Filter...)}.
*/
<T extends B> T filters(Filter... filters);
/**
* Add a filter for specific URL patterns.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#addFilter(Filter, String...)}.
*/
<T extends B> 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.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#defaultRequest(RequestBuilder)}.
*/
<T extends B> T defaultRequest(RequestBuilder requestBuilder);
/**
* Define a global expectation that should <em>always</em> be applied to
* every response.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#alwaysExpect(ResultMatcher)}.
*/
<T extends B> T alwaysExpect(ResultMatcher resultMatcher);
/**
* Whether to handle HTTP OPTIONS requests.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#dispatchOptions(boolean)}.
*/
<T extends B> T dispatchOptions(boolean dispatchOptions);
/**
* Allow customization of {@code DispatcherServlet}.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#addDispatcherServletCustomizer(DispatcherServletCustomizer)}.
*/
<T extends B> T dispatcherServletCustomizer(DispatcherServletCustomizer customizer);
/**
* Add a {@code MockMvcConfigurer} that automates MockMvc setup.
* <p>This is delegated to
* {@link ConfigurableMockMvcBuilder#apply(MockMvcConfigurer)}.
*/
<T extends B> 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 <S> a self reference to the spec type
*/
interface RequestHeadersUriSpec<S extends RequestHeadersSpec<S>> extends UriSpec<S>, RequestHeadersSpec<S> {
}
/**
* Specification for providing the body and the URI of a request.
*/
interface RequestBodyUriSpec extends RequestBodySpec, RequestHeadersUriSpec<RequestBodySpec> {
}
/**
* 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 <B>} and then apply assertions.
* @param bodyType the expected body type
*/
<B> BodySpec<B, ?> expectBody(Class<B> bodyType);
/**
* Alternative to {@link #expectBody(Class)} that accepts information
* about a target type with generics.
*/
<B> BodySpec<B, ?> expectBody(ParameterizedTypeReference<B> 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.
* <p>If a single {@link Error} or {@link RuntimeException} is thrown,
* it will be rethrown.
* <p>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}.
* <p>This feature is similar to the {@code SoftAssertions} support in
* AssertJ and the {@code assertAll()} support in JUnit Jupiter.
*
* <h4>Example</h4>
* <pre class="code">
* restTestClient.get().uri("/hello").exchange()
* .expectAll(
* responseSpec -&gt; responseSpec.expectStatus().isOk(),
* responseSpec -&gt; responseSpec.expectBody(String.class).isEqualTo("Hello, World!")
* );
* </pre>
* @param consumers the list of {@code ResponseSpec} consumers
*/
ResponseSpec expectAll(ResponseSpecConsumer... consumers);
/**
* Exit the chained flow in order to consume the response body
* externally.
*/
<T> EntityExchangeResult<T> returnResult(Class<T> elementClass);
/**
* Alternative to {@link #returnResult(Class)} that accepts information
* about a target type with generics.
*/
<T> EntityExchangeResult<T> returnResult(ParameterizedTypeReference<T> elementTypeRef);
/**
* {@link Consumer} of a {@link RestTestClient.ResponseSpec}.
* @see RestTestClient.ResponseSpec#expectAll(RestTestClient.ResponseSpec.ResponseSpecConsumer...)
*/
@FunctionalInterface
interface ResponseSpecConsumer extends Consumer<ResponseSpec> {
}
}
/**
* Spec for expectations on the response body content.
*/
interface BodyContentSpec {
/**
* Assert the response body is empty and return the exchange result.
*/
EntityExchangeResult<Void> 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 <em>lenient</em> checking (extensible
* and non-strict array ordering).
* <p>Use of this method requires the
* <a href="https://jsonassert.skyscreamer.org/">JSONassert</a> 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}.
* <p>Use of this method requires the
* <a href="https://jsonassert.skyscreamer.org/">JSONassert</a> 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.
* <p>Use of this method requires the
* <a href="https://github.com/xmlunit/xmlunit">XMLUnit</a> 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.
* <p>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.
* <p>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<String, String> namespaces, Object... args);
/**
* Access to response body assertions using a
* <a href="https://github.com/jayway/JsonPath">JsonPath</a> 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<byte[]> returnResult();
}
/**
* Spec for expectations on the response body decoded to a single Object.
*
* @param <S> a self reference to the spec type
* @param <B> the body type
*/
interface BodySpec<B, S extends BodySpec<B, S>> {
/**
* Transform the extracted the body with a function, for example, extracting a
* property, and assert the mapped value with a {@link Matcher}.
*/
<T extends S, R> T value(Function<B, R> bodyMapper, Matcher<? super R> matcher);
/**
* Assert the extracted body with a {@link Consumer}.
*/
<T extends S> T value(Consumer<B> consumer);
/**
* Assert the exchange result with the given {@link Consumer}.
*/
<T extends S> T consumeWith(Consumer<EntityExchangeResult<B>> consumer);
/**
* Exit the chained API and return an {@code EntityExchangeResult} with the
* decoded response content.
*/
EntityExchangeResult<B> returnResult();
/**
* Assert the extracted body is equal to the given value.
*/
<T extends S> T isEqualTo(B expected);
}
/**
* Specification for providing the URI of a request.
*
* @param <S> a self reference to the spec type
*/
interface UriSpec<S extends RequestHeadersSpec<?>> {
/**
* Specify the URI using an absolute, fully constructed {@link java.net.URI}.
* <p>If a {@link UriBuilderFactory} was configured for the client with
* a base URI, that base URI will <strong>not</strong> 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)} &mdash; 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.
* <p>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.
* <p>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<String, ?> 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<UriBuilder, URI> uriFunction);
}
/**
* Specification for adding request headers and performing an exchange.
*
* @param <S> a self reference to the spec type
*/
interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
/**
* 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<MultiValueMap<String, String>> cookiesConsumer);
/**
* Set the value of the {@code If-Modified-Since} header.
* <p>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<HttpHeaders> 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<Map<String, Object>> 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<RequestBodySpec> {
/**
* 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.
* <p>This can be useful for applying pre-packaged customizations.
* @param builderConsumer the consumer to apply
*/
Builder apply(Consumer<Builder> 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<MultiValueMap<String, String>> 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<HttpHeaders> 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);
}
}

View File

@ -0,0 +1,101 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<RouterFunctionSpec>
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;
}
}

View File

@ -0,0 +1,170 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<RestTestClient.ControllerSpec>
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<RequestMappingHandlerMapping> factory) {
this.mockMvcBuilder.setCustomHandlerMapping(factory);
return this;
}
@Override
protected ConfigurableMockMvcBuilder<?> getMockMvcBuilder() {
return this.mockMvcBuilder;
}
}

View File

@ -0,0 +1,250 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<? super Integer> 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<Integer> 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;
}
}

View File

@ -0,0 +1,205 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<String, String> namespaces, Object... args) {
this.bodySpec = spec;
this.xpathHelper = initXpathHelper(expression, namespaces, args);
}
private static XpathExpectationsHelper initXpathHelper(
String expression, @Nullable Map<String, String> 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<? super String> matcher){
return assertWith(() -> this.xpathHelper.assertString(getContent(), getCharset(), matcher));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNumber(byte[], String, Matcher)}.
*/
public RestTestClient.BodyContentSpec number(Matcher<? super Double> matcher){
return assertWith(() -> this.xpathHelper.assertNumber(getContent(), getCharset(), matcher));
}
/**
* Delegates to {@link XpathExpectationsHelper#assertNodeCount(byte[], String, Matcher)}.
*/
public RestTestClient.BodyContentSpec nodeCount(Matcher<? super Integer> matcher){
return assertWith(() -> this.xpathHelper.assertNodeCount(getContent(), getCharset(), matcher));
}
/**
* Consume the result of the XPath evaluation as a String.
*/
public RestTestClient.BodyContentSpec string(Consumer<String> 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<Double> 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<Integer> 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;
}
}

View File

@ -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;

View File

@ -0,0 +1,147 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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());
}
}

View File

@ -0,0 +1,320 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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());
}
}

View File

@ -0,0 +1,217 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<String, Object> getInstruments() {
return Map.of("clarinets", List.of());
}
@GetMapping("/music/people")
public MultiValueMap<String, Person> get() {
MultiValueMap<String, Person> 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;
}
}
}

View File

@ -0,0 +1,114 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<String> handle(IllegalStateException ex) {
return ResponseEntity.status(400).body("Handled exception");
}
}
private static class TestConsumer<T> implements Consumer<T> {
private T value;
public T getValue() {
return this.value;
}
@Override
public void accept(T t) {
this.value = t;
}
}
}

View File

@ -0,0 +1,266 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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);
}
}
}

View File

@ -0,0 +1,59 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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");
}
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<Void> handleHeader(@RequestHeader("h1") String myHeader) {
String value = myHeader + "-out";
return ResponseEntity.ok().header("h1", value).build();
}
@GetMapping("header-multi-value")
ResponseEntity<Void> multiValue() {
return ResponseEntity.ok().header("h1", "v1", "v2", "v3").build();
}
@GetMapping("cookie-echo")
ResponseEntity<Void> handleCookie(@CookieValue("k1") String cookieValue) {
HttpHeaders headers = new HttpHeaders();
headers.set("Set-Cookie", "k1=" + cookieValue);
return new ResponseEntity<>(headers, HttpStatus.OK);
}
}
}

View File

@ -0,0 +1,171 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<Person> 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<String> 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;
}
}
}

View File

@ -0,0 +1,69 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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 + "']";
}
}

View File

@ -0,0 +1,157 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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<Person> 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<List<Person>>() {}).isEqualTo(expected);
}
@Test
void entityListWithConsumer() {
this.client.get()
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody(new ParameterizedTypeReference<List<Person>>() {})
.value(people ->
assertThat(people).contains(new Person("Jason"))
);
}
@Test
void entityMap() {
Map<String, Person> 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<Map<String, Person>>() {}).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<Person> getPersons() {
return List.of(new Person("Jane"), new Person("Jason"), new Person("John"));
}
@GetMapping(params = "map", produces = MediaType.APPLICATION_JSON_VALUE)
Map<String, Person> getPersonsAsMap() {
Map<String, Person> 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<String> savePerson(@RequestBody Person person) {
return ResponseEntity.created(URI.create("/persons/" + person.getName())).build();
}
}
}

View File

@ -0,0 +1,330 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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.getResponseBody().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<Map<String, Object>>() {
});
assertThat(result.getResponseBody().get("uri")).isEqualTo("/test");
}
}
@RestController
static class TestController {
@RequestMapping(path = {"/test", "/test/*"}, produces = "application/json")
public Map<String, Object> 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()
);
}
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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:<bogus> but was:<hello>""");
}
@RestController
static class TestController {
@GetMapping("/test")
String handle() {
return "hello";
}
}
}

View File

@ -0,0 +1,196 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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 = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<persons>
<person><name>Jane</name></person>
<person><name>Jason</name></person>
<person><name>John</name></person>
</persons>
""";
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 =
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>" +
"<person><name>John</name></person>";
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<Person> persons = new ArrayList<>();
public PersonsWrapper() {
}
public PersonsWrapper(List<Person> persons) {
this.persons.addAll(persons);
}
public PersonsWrapper(Person... persons) {
this.persons.addAll(Arrays.asList(persons));
}
public List<Person> 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<Object> savepersons(@RequestBody Person person) {
URI location = URI.create(String.format("/persons/%s", person.getName()));
return ResponseEntity.created(location).build();
}
}
}

View File

@ -0,0 +1,79 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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!";
}
}
}

View File

@ -0,0 +1,61 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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!";
}
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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!");
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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!");
}
}

View File

@ -0,0 +1,56 @@
/*
* Copyright 2002-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.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!");
}
}

View File

@ -94,13 +94,14 @@
<suppress files="src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]util[\\/].+Helper" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]web[\\/]client[\\/]match[\\/].+Matchers" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]web[\\/]reactive[\\/]server[\\/].+" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]web[\\/]server[\\/].+" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]main[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]web[\\/]servlet[\\/]result[\\/].+Matchers" checks="IllegalImport" id="bannedHamcrestImports"/>
<!-- spring-test - test -->
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/].+TestNGTests" checks="IllegalImport" id="bannedTestNGImports"/>
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]context[\\/]aot[\\/]samples[\\/]web[\\/].+Tests" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]context[\\/]junit[\\/]jupiter[\\/]web[\\/].+Tests" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]util[\\/].+Tests" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]web[\\/](client|reactive|servlet)[\\/].+Tests" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]web[\\/](client|server|reactive|servlet)[\\/].+Tests" checks="IllegalImport" id="bannedHamcrestImports"/>
<suppress files="src[\\/]test[\\/]java[\\/]org[\\/]springframework[\\/]test[\\/]context[\\/](aot|junit4)" checks="SpringJUnit5"/>
<suppress files="AutowiredConfigurationErrorsIntegrationTests" checks="SpringJUnit5" message="Lifecycle method .+ should not be private"/>
<suppress files="org[\\/]springframework[\\/]test[\\/]context[\\/].+[\\/](ExpectedExceptionSpringRunnerTests|StandardJUnit4FeaturesTests|ProgrammaticTxMgmtTestNGTests)" checks="RegexpSinglelineJava" id="expectedExceptionAnnotation"/>