diff --git a/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpResponse.java b/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpResponse.java index 8c584848089..c2e950025f4 100644 --- a/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpResponse.java +++ b/spring-test/src/main/java/org/springframework/mock/http/client/reactive/MockClientHttpResponse.java @@ -80,7 +80,7 @@ public class MockClientHttpResponse implements ClientHttpResponse { public HttpHeaders getHeaders() { if (!getCookies().isEmpty() && this.headers.get(HttpHeaders.SET_COOKIE) == null) { getCookies().values().stream().flatMap(Collection::stream) - .forEach(cookie -> getHeaders().add(HttpHeaders.SET_COOKIE, cookie.toString())); + .forEach(cookie -> this.headers.add(HttpHeaders.SET_COOKIE, cookie.toString())); } return this.headers; } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java new file mode 100644 index 00000000000..9d1cad06598 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/CookieAssertions.java @@ -0,0 +1,220 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import java.time.Duration; +import java.util.function.Consumer; + +import org.hamcrest.Matcher; +import org.hamcrest.MatcherAssert; + +import org.springframework.http.ResponseCookie; +import org.springframework.test.util.AssertionErrors; + +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Assertions on cookies of the response. + * @author Rossen Stoyanchev + */ +public class CookieAssertions { + + private final ExchangeResult exchangeResult; + + private final WebTestClient.ResponseSpec responseSpec; + + + public CookieAssertions(ExchangeResult exchangeResult, WebTestClient.ResponseSpec responseSpec) { + this.exchangeResult = exchangeResult; + this.responseSpec = responseSpec; + } + + + /** + * Expect a header with the given name to match the specified values. + */ + public WebTestClient.ResponseSpec valueEquals(String name, String value) { + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + AssertionErrors.assertEquals(message, value, getCookie(name).getValue()); + }); + return this.responseSpec; + } + + /** + * Assert the first value of the response cookie with a Hamcrest {@link Matcher}. + */ + public WebTestClient.ResponseSpec value(String name, Matcher matcher) { + String value = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + MatcherAssert.assertThat(message, value, matcher); + }); + return this.responseSpec; + } + + /** + * Consume the value of the response cookie. + */ + public WebTestClient.ResponseSpec value(String name, Consumer consumer) { + String value = getCookie(name).getValue(); + this.exchangeResult.assertWithDiagnostics(() -> consumer.accept(value)); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is present. + */ + public WebTestClient.ResponseSpec exists(String name) { + getCookie(name); + return this.responseSpec; + } + + /** + * Expect that the cookie with the given name is not present. + */ + public WebTestClient.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(() -> AssertionErrors.fail(message)); + } + return this.responseSpec; + } + + /** + * Assert a cookie's maxAge attribute. + */ + public WebTestClient.ResponseSpec maxAge(String name, Duration expected) { + Duration maxAge = getCookie(name).getMaxAge(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + AssertionErrors.assertEquals(message, expected, maxAge); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's maxAge attribute with a Hamcrest {@link Matcher}. + */ + public WebTestClient.ResponseSpec maxAge(String name, Matcher matcher) { + long maxAge = getCookie(name).getMaxAge().getSeconds(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " maxAge"; + assertThat(message, maxAge, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's path attribute. + */ + public WebTestClient.ResponseSpec path(String name, String expected) { + String path = getCookie(name).getPath(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + AssertionErrors.assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's path attribute with a Hamcrest {@link Matcher}. + */ + public WebTestClient.ResponseSpec path(String name, Matcher matcher) { + String path = getCookie(name).getPath(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " path"; + assertThat(message, path, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's domain attribute. + */ + public WebTestClient.ResponseSpec domain(String name, String expected) { + String path = getCookie(name).getDomain(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + AssertionErrors.assertEquals(message, expected, path); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's domain attribute with a Hamcrest {@link Matcher}. + */ + public WebTestClient.ResponseSpec domain(String name, Matcher matcher) { + String domain = getCookie(name).getDomain(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " domain"; + assertThat(message, domain, matcher); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's secure attribute. + */ + public WebTestClient.ResponseSpec secure(String name, boolean expected) { + boolean isSecure = getCookie(name).isSecure(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " secure"; + AssertionErrors.assertEquals(message, expected, isSecure); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's httpOnly attribute. + */ + public WebTestClient.ResponseSpec httpOnly(String name, boolean expected) { + boolean isHttpOnly = getCookie(name).isHttpOnly(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " secure"; + AssertionErrors.assertEquals(message, expected, isHttpOnly); + }); + return this.responseSpec; + } + + /** + * Assert a cookie's sameSite attribute. + */ + public WebTestClient.ResponseSpec sameSite(String name, String expected) { + String sameSite = getCookie(name).getSameSite(); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name) + " secure"; + AssertionErrors.assertEquals(message, expected, sameSite); + }); + return this.responseSpec; + } + + + private ResponseCookie getCookie(String name) { + ResponseCookie cookie = this.exchangeResult.getResponseCookies().getFirst(name); + if (cookie == null) { + String message = "No cookie with name '" + name + "'"; + this.exchangeResult.assertWithDiagnostics(() -> AssertionErrors.fail(message)); + } + return cookie; + } + + private String getMessage(String cookie) { + return "Response cookie '" + cookie + "'"; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java index 65e2e366149..2c3bff24944 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/DefaultWebTestClient.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2020 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. @@ -306,8 +306,8 @@ class DefaultWebTestClient implements WebTestClient { public ResponseSpec exchange() { ClientResponse clientResponse = this.bodySpec.exchange().block(getTimeout()); Assert.state(clientResponse != null, "No ClientResponse"); - WiretapConnector.Info info = wiretapConnector.claimRequest(this.requestId); - return new DefaultResponseSpec(info, clientResponse, this.uriTemplate, getTimeout()); + ExchangeResult result = wiretapConnector.getExchangeResult(this.requestId, this.uriTemplate, getTimeout()); + return new DefaultResponseSpec(result, clientResponse, getTimeout()); } } @@ -321,10 +321,8 @@ class DefaultWebTestClient implements WebTestClient { private final Duration timeout; - DefaultResponseSpec(WiretapConnector.Info wiretapInfo, ClientResponse response, - @Nullable String uriTemplate, Duration timeout) { - - this.exchangeResult = wiretapInfo.createExchangeResult(timeout, uriTemplate); + DefaultResponseSpec(ExchangeResult exchangeResult, ClientResponse response, Duration timeout) { + this.exchangeResult = exchangeResult; this.response = response; this.timeout = timeout; } @@ -339,6 +337,11 @@ class DefaultWebTestClient implements WebTestClient { return new HeaderAssertions(this.exchangeResult, this); } + @Override + public CookieAssertions expectCookie() { + return new CookieAssertions(this.exchangeResult, this); + } + @Override public BodySpec expectBody(Class bodyType) { B body = this.response.bodyToMono(bodyType).block(this.timeout); @@ -380,7 +383,14 @@ class DefaultWebTestClient implements WebTestClient { @Override public FluxExchangeResult returnResult(Class elementClass) { - Flux body = this.response.bodyToFlux(elementClass); + Flux body; + if (elementClass.equals(Void.class)) { + this.response.releaseBody().block(); + body = Flux.empty(); + } + else { + body = this.response.bodyToFlux(elementClass); + } return new FluxExchangeResult<>(this.exchangeResult, body); } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java index 77e514dd757..ff1c20d5020 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/ExchangeResult.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2020 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. @@ -72,6 +72,9 @@ public class ExchangeResult { @Nullable private final String uriTemplate; + @Nullable + final Object mockServerResult; + /** * Create an instance with an HTTP request and response along with promises @@ -83,9 +86,11 @@ public class ExchangeResult { * @param responseBody capture of serialized response body content * @param timeout how long to wait for content to materialize * @param uriTemplate the URI template used to set up the request, if any + * @param serverResult the result of a mock server exchange if applicable. */ ExchangeResult(ClientHttpRequest request, ClientHttpResponse response, - Mono requestBody, Mono responseBody, Duration timeout, @Nullable String uriTemplate) { + Mono requestBody, Mono responseBody, Duration timeout, @Nullable String uriTemplate, + @Nullable Object serverResult) { Assert.notNull(request, "ClientHttpRequest is required"); Assert.notNull(response, "ClientHttpResponse is required"); @@ -98,6 +103,7 @@ public class ExchangeResult { this.responseBody = responseBody; this.timeout = timeout; this.uriTemplate = uriTemplate; + this.mockServerResult = serverResult; } /** @@ -110,6 +116,7 @@ public class ExchangeResult { this.responseBody = other.responseBody; this.timeout = other.timeout; this.uriTemplate = other.uriTemplate; + this.mockServerResult = other.mockServerResult; } @@ -195,6 +202,16 @@ public class ExchangeResult { return this.responseBody.block(this.timeout); } + /** + * Return the result from the mock server exchange, if applicable, for + * further assertions on the state of the server response. + * @since 5.3 + * @see org.springframework.test.web.servlet.client.MockMvcTestClient#resultActionsFor(ExchangeResult) + */ + @Nullable + public Object getMockServerResult() { + return this.mockServerResult; + } /** * Execute the given Runnable, catch any {@link AssertionError}, decorate @@ -222,7 +239,8 @@ public class ExchangeResult { "< " + getStatus() + " " + getStatus().getReasonPhrase() + "\n" + "< " + formatHeaders(getResponseHeaders(), "\n< ") + "\n" + "\n" + - formatBody(getResponseHeaders().getContentType(), this.responseBody) +"\n"; + formatBody(getResponseHeaders().getContentType(), this.responseBody) +"\n" + + formatMockServerResult(); } private String formatHeaders(HttpHeaders headers, String delimiter) { @@ -252,4 +270,10 @@ public class ExchangeResult { .block(this.timeout); } + private String formatMockServerResult() { + return (this.mockServerResult != null ? + "\n====================== MockMvc (Server) ===============================\n" + + this.mockServerResult + "\n" : ""); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java index efb0d904ece..82813c6894a 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/HeaderAssertions.java @@ -16,6 +16,7 @@ package org.springframework.test.web.reactive.server; +import java.net.URI; import java.util.Arrays; import java.util.List; import java.util.function.Consumer; @@ -31,6 +32,10 @@ import org.springframework.lang.Nullable; import org.springframework.test.util.AssertionErrors; import org.springframework.util.CollectionUtils; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertNotNull; +import static org.springframework.test.util.AssertionErrors.assertTrue; + /** * Assertions on headers of the response. * @@ -57,7 +62,42 @@ public class HeaderAssertions { * Expect a header with the given name to match the specified values. */ public WebTestClient.ResponseSpec valueEquals(String headerName, String... values) { - return assertHeader(headerName, Arrays.asList(values), getHeaders().get(headerName)); + return assertHeader(headerName, Arrays.asList(values), getHeaders().getOrEmpty(headerName)); + } + + /** + * Expect a header with the given name to match the given long value. + * @since 5.3 + */ + public WebTestClient.ResponseSpec valueEquals(String headerName, long value) { + String actual = getHeaders().getFirst(headerName); + this.exchangeResult.assertWithDiagnostics(() -> + assertTrue("Response does not contain header '" + headerName + "'", actual != null)); + return assertHeader(headerName, value, Long.parseLong(actual)); + } + + /** + * Expect a header with the given name to match the specified long value + * parsed into a date using the preferred date format described in RFC 7231. + *

An {@link AssertionError} is thrown if the response does not contain + * the specified header, or if the supplied {@code value} does not match the + * primary header value. + * @since 5.3 + */ + public WebTestClient.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("Response header '" + headerName + "'='" + headerValue + "' " + + "does not match expected value '" + headers.getFirst("expected") + "'", + headers.getFirstDate("expected"), headers.getFirstDate("actual")); + }); + return this.responseSpec; } /** @@ -106,8 +146,11 @@ public class HeaderAssertions { * @since 5.1 */ public WebTestClient.ResponseSpec value(String name, Matcher matcher) { - String value = getRequiredValue(name); - this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat(value, matcher)); + String value = getHeaders().getFirst(name); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + MatcherAssert.assertThat(message, value, matcher); + }); return this.responseSpec; } @@ -118,8 +161,11 @@ public class HeaderAssertions { * @since 5.3 */ public WebTestClient.ResponseSpec values(String name, Matcher> matcher) { - List values = getRequiredValues(name); - this.exchangeResult.assertWithDiagnostics(() -> MatcherAssert.assertThat(values, matcher)); + List values = getHeaders().get(name); + this.exchangeResult.assertWithDiagnostics(() -> { + String message = getMessage(name); + MatcherAssert.assertThat(message, values, matcher); + }); return this.responseSpec; } @@ -154,7 +200,8 @@ public class HeaderAssertions { private List getRequiredValues(String name) { List values = getHeaders().get(name); if (CollectionUtils.isEmpty(values)) { - AssertionErrors.fail(getMessage(name) + " not found"); + this.exchangeResult.assertWithDiagnostics(() -> + AssertionErrors.fail(getMessage(name) + " not found")); } return values; } @@ -249,6 +296,14 @@ public class HeaderAssertions { return assertHeader("Last-Modified", lastModified, getHeaders().getLastModified()); } + /** + * Expect a "Location" header with the given value. + * @since 5.3 + */ + public WebTestClient.ResponseSpec location(String location) { + return assertHeader("Location", URI.create(location), getHeaders().getLocation()); + } + private HttpHeaders getHeaders() { return this.exchangeResult.getResponseHeaders(); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerClientHttpResponse.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerClientHttpResponse.java new file mode 100644 index 00000000000..8fe12d92659 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/MockServerClientHttpResponse.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.reactive.server; + +import org.springframework.http.client.reactive.ClientHttpResponse; + +/** + * Simple {@link ClientHttpResponse} extension that also exposes a result object + * from the underlying mock server exchange for further assertions on the state + * of the server response after the request is performed. + * + * @author Rossen Stoyanchev + * @since 5.3 + */ +public interface MockServerClientHttpResponse extends ClientHttpResponse { + + /** + * Return the result object with the server request and response. + */ + Object getServerResult(); + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java index 6b55d52075e..4018ac1acde 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WebTestClient.java @@ -172,7 +172,7 @@ public interface WebTestClient { // Static factory methods /** - * Use this server setup to test one `@Controller` at a time. + * Use this server setup to test one {@code @Controller} at a time. * This option loads the default configuration of * {@link org.springframework.web.reactive.config.EnableWebFlux @EnableWebFlux}. * There are builder methods to customize the Java config. The resulting @@ -229,8 +229,8 @@ public interface WebTestClient { } /** - * This server setup option allows you to connect to a running server via - * Reactor Netty. + * This server setup option allows you to connect to a live server through + * a Reactor Netty client connector. *

 	 * WebTestClient client = WebTestClient.bindToServer()
 	 *         .baseUrl("http://localhost:8080")
@@ -244,11 +244,6 @@ public interface WebTestClient {
 
 	/**
 	 * A variant of {@link #bindToServer()} with a pre-configured connector.
-	 * 

-	 * WebTestClient client = WebTestClient.bindToServer()
-	 *         .baseUrl("http://localhost:8080")
-	 *         .build();
-	 * 
* @return chained API to customize client config * @since 5.0.2 */ @@ -769,6 +764,12 @@ public interface WebTestClient { */ HeaderAssertions expectHeader(); + /** + * Assertions on the cookies of the response. + * @since 5.3 + */ + CookieAssertions expectCookie(); + /** * Consume and decode the response body to a single object of type * {@code } and then apply assertions. @@ -802,18 +803,13 @@ public interface WebTestClient { BodyContentSpec expectBody(); /** - * Exit the chained API and consume the response body externally. This - * is useful for testing infinite streams (e.g. SSE) where you need to - * to assert decoded objects as they come and then cancel at some point - * when test objectives are met. Consider using {@code StepVerifier} - * from {@literal "reactor-test"} to assert the {@code Flux} stream - * of decoded objects. + * Exit the chained flow in order to consume the response body + * externally, e.g. via {@link reactor.test.StepVerifier}. * - *

Note: Do not use this option for cases where there - * is no content (e.g. 204, 4xx) or you're not interested in the content. - * For such cases you can use {@code expectBody().isEmpty()} or - * {@code expectBody(Void.class)} which ensures that resources are - * released regardless of whether the response has content or not. + *

Note that when {@code Void.class} is passed in, the response body + * is consumed and released. If no content is expected, then consider + * using {@code .expectBody().isEmpty()} instead which asserts that + * there is no content. */ FluxExchangeResult returnResult(Class elementClass); diff --git a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java index b904da963d2..76329b6a6b0 100644 --- a/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java +++ b/spring-test/src/main/java/org/springframework/test/web/reactive/server/WiretapConnector.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2020 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. @@ -53,7 +53,7 @@ class WiretapConnector implements ClientHttpConnector { private final ClientHttpConnector delegate; - private final Map exchanges = new ConcurrentHashMap<>(); + private final Map exchanges = new ConcurrentHashMap<>(); WiretapConnector(ClientHttpConnector delegate) { @@ -79,43 +79,48 @@ class WiretapConnector implements ClientHttpConnector { String requestId = wrappedRequest.getHeaders().getFirst(header); Assert.state(requestId != null, () -> "No \"" + header + "\" header"); WiretapClientHttpResponse wrappedResponse = new WiretapClientHttpResponse(response); - this.exchanges.put(requestId, new Info(wrappedRequest, wrappedResponse)); + this.exchanges.put(requestId, new ClientExchangeInfo(wrappedRequest, wrappedResponse)); return wrappedResponse; }); } /** - * Retrieve the {@link Info} for the given "request-id" header value. + * Create the {@link ExchangeResult} for the given "request-id" header value. */ - public Info claimRequest(String requestId) { - Info info = this.exchanges.remove(requestId); - Assert.state(info != null, () -> { + ExchangeResult getExchangeResult(String requestId, @Nullable String uriTemplate, Duration timeout) { + ClientExchangeInfo clientInfo = this.exchanges.remove(requestId); + Assert.state(clientInfo != null, () -> { String header = WebTestClient.WEBTESTCLIENT_REQUEST_ID; return "No match for " + header + "=" + requestId; }); - return info; + return new ExchangeResult(clientInfo.getRequest(), clientInfo.getResponse(), + clientInfo.getRequest().getRecorder().getContent(), + clientInfo.getResponse().getRecorder().getContent(), + timeout, uriTemplate, + clientInfo.getResponse().getMockServerResult()); } /** * Holder for {@link WiretapClientHttpRequest} and {@link WiretapClientHttpResponse}. */ - class Info { + private static class ClientExchangeInfo { private final WiretapClientHttpRequest request; private final WiretapClientHttpResponse response; - - public Info(WiretapClientHttpRequest request, WiretapClientHttpResponse response) { + public ClientExchangeInfo(WiretapClientHttpRequest request, WiretapClientHttpResponse response) { this.request = request; this.response = response; } + public WiretapClientHttpRequest getRequest() { + return this.request; + } - public ExchangeResult createExchangeResult(Duration timeout, @Nullable String uriTemplate) { - return new ExchangeResult(this.request, this.response, this.request.getRecorder().getContent(), - this.response.getRecorder().getContent(), timeout, uriTemplate); + public WiretapClientHttpResponse getResponse() { + return this.response; } } @@ -275,6 +280,12 @@ class WiretapConnector implements ClientHttpConnector { public Flux getBody() { return Flux.from(this.recorder.getPublisherToUse()); } + + @Nullable + public Object getMockServerResult() { + return (getDelegate() instanceof MockServerClientHttpResponse ? + ((MockServerClientHttpResponse) getDelegate()).getServerResult() : null); + } } } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java new file mode 100644 index 00000000000..0e8dd4c2aa7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/AbstractMockMvcServerSpec.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.servlet.client; + +import javax.servlet.Filter; + +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.test.web.reactive.server.WebTestClient; +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 MockMvcTestClient.MockMvcServerSpec} + * that simply delegates to a {@link ConfigurableMockMvcBuilder} supplied by + * the concrete sub-classes. + * + * @author Rossen Stoyanchev + * @since 5.3 + * @param the type of the concrete sub-class spec + */ +abstract class AbstractMockMvcServerSpec> + implements MockMvcTestClient.MockMvcServerSpec { + + @Override + public T filters(Filter... filters) { + getMockMvcBuilder().addFilters(filters); + return self(); + } + + public final T filter(Filter filter, String... urlPatterns) { + getMockMvcBuilder().addFilter(filter, urlPatterns); + return self(); + } + + @Override + public T defaultRequest(RequestBuilder requestBuilder) { + getMockMvcBuilder().defaultRequest(requestBuilder); + return self(); + } + + @Override + public T alwaysExpect(ResultMatcher resultMatcher) { + getMockMvcBuilder().alwaysExpect(resultMatcher); + return self(); + } + + @Override + public T dispatchOptions(boolean dispatchOptions) { + getMockMvcBuilder().dispatchOptions(dispatchOptions); + return self(); + } + + @Override + public T dispatcherServletCustomizer(DispatcherServletCustomizer customizer) { + getMockMvcBuilder().addDispatcherServletCustomizer(customizer); + return self(); + } + + @Override + public T apply(MockMvcConfigurer configurer) { + getMockMvcBuilder().apply(configurer); + return self(); + } + + @SuppressWarnings("unchecked") + private T self() { + return (T) this; + } + + /** + * Return the concrete {@link ConfigurableMockMvcBuilder} to delegate + * configuration methods and to use to create the {@link MockMvc}. + */ + protected abstract ConfigurableMockMvcBuilder getMockMvcBuilder(); + + @Override + public WebTestClient.Builder configureClient() { + MockMvc mockMvc = getMockMvcBuilder().build(); + ClientHttpConnector connector = new MockMvcHttpConnector(mockMvc); + return WebTestClient.bindToServer(connector); + } + + @Override + public WebTestClient build() { + return configureClient().build(); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java new file mode 100644 index 00000000000..1c6c3577798 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/ApplicationContextMockMvcSpec.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.servlet.client; + +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 Rossen Stoyanchev + * @since 5.3 + */ +class ApplicationContextMockMvcSpec extends AbstractMockMvcServerSpec { + + private final DefaultMockMvcBuilder mockMvcBuilder; + + + public ApplicationContextMockMvcSpec(WebApplicationContext context) { + this.mockMvcBuilder = MockMvcBuilders.webAppContextSetup(context); + } + + @Override + protected ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java new file mode 100644 index 00000000000..705e89432b7 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcHttpConnector.java @@ -0,0 +1,301 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.servlet.client; + +import java.io.StringWriter; +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import javax.servlet.http.Cookie; + +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.http.ResponseCookie; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.http.codec.multipart.Part; +import org.springframework.lang.Nullable; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpResponse; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockPart; +import org.springframework.test.web.reactive.server.MockServerClientHttpResponse; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.FlashMap; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; + +/** + * Connector that handles requests by invoking a {@link MockMvc} rather than + * making actual requests over HTTP. + * + * @author Rossen Stoyanchev + * @since 5.3 + */ +public class MockMvcHttpConnector implements ClientHttpConnector { + + private static final DefaultPartHttpMessageReader MULTIPART_READER = new DefaultPartHttpMessageReader(); + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + + private final MockMvc mockMvc; + + + public MockMvcHttpConnector(MockMvc mockMvc) { + this.mockMvc = mockMvc; + } + + + @Override + public Mono connect( + HttpMethod method, URI uri, Function> requestCallback) { + + RequestBuilder requestBuilder = adaptRequest(method, uri, requestCallback); + try { + MvcResult mvcResult = this.mockMvc.perform(requestBuilder).andReturn(); + if (mvcResult.getRequest().isAsyncStarted()) { + mvcResult.getAsyncResult(); + mvcResult = this.mockMvc.perform(asyncDispatch(mvcResult)).andReturn(); + } + return Mono.just(adaptResponse(mvcResult)); + } + catch (Exception ex) { + return Mono.error(ex); + } + } + + private RequestBuilder adaptRequest( + HttpMethod httpMethod, URI uri, Function> requestCallback) { + + MockClientHttpRequest httpRequest = new MockClientHttpRequest(httpMethod, uri); + + AtomicReference contentRef = new AtomicReference<>(); + httpRequest.setWriteHandler(dataBuffers -> + DataBufferUtils.join(dataBuffers) + .doOnNext(buffer -> { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + DataBufferUtils.release(buffer); + contentRef.set(bytes); + }) + .then()); + + // Initialize the client request + requestCallback.apply(httpRequest).block(TIMEOUT); + + MockHttpServletRequestBuilder requestBuilder = + initRequestBuilder(httpMethod, uri, httpRequest, contentRef.get()); + + requestBuilder.headers(httpRequest.getHeaders()); + for (List cookies : httpRequest.getCookies().values()) { + for (HttpCookie cookie : cookies) { + requestBuilder.cookie(new Cookie(cookie.getName(), cookie.getValue())); + } + } + + return requestBuilder; + } + + private MockHttpServletRequestBuilder initRequestBuilder( + HttpMethod httpMethod, URI uri, MockClientHttpRequest httpRequest, @Nullable byte[] bytes) { + + String contentType = httpRequest.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + if (!StringUtils.startsWithIgnoreCase(contentType, "multipart/")) { + MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.request(httpMethod, uri); + if (!ObjectUtils.isEmpty(bytes)) { + requestBuilder.content(bytes); + } + return requestBuilder; + } + + // Parse the multipart request in order to adapt to Servlet Part's + MockMultipartHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.multipart(uri); + + Assert.notNull(bytes, "No multipart content"); + ReactiveHttpInputMessage inputMessage = MockServerHttpRequest.post(uri.toString()) + .headers(httpRequest.getHeaders()) + .body(Mono.just(DefaultDataBufferFactory.sharedInstance.wrap(bytes))); + + MULTIPART_READER.read(ResolvableType.forClass(Part.class), inputMessage, Collections.emptyMap()) + .flatMap(part -> + DataBufferUtils.join(part.content()) + .doOnNext(buffer -> { + byte[] partBytes = new byte[buffer.readableByteCount()]; + buffer.read(partBytes); + DataBufferUtils.release(buffer); + + // Adapt to javax.servlet.http.Part... + MockPart mockPart = (part instanceof FilePart ? + new MockPart(part.name(), ((FilePart) part).filename(), partBytes) : + new MockPart(part.name(), partBytes)); + mockPart.getHeaders().putAll(part.headers()); + requestBuilder.part(mockPart); + })) + .blockLast(TIMEOUT); + + return requestBuilder; + } + + private MockClientHttpResponse adaptResponse(MvcResult mvcResult) { + MockClientHttpResponse clientResponse = new MockMvcServerClientHttpResponse(mvcResult); + MockHttpServletResponse servletResponse = mvcResult.getResponse(); + for (String header : servletResponse.getHeaderNames()) { + for (String value : servletResponse.getHeaders(header)) { + clientResponse.getHeaders().add(header, value); + } + } + if (servletResponse.getForwardedUrl() != null) { + clientResponse.getHeaders().add("Forwarded-Url", servletResponse.getForwardedUrl()); + } + for (Cookie cookie : servletResponse.getCookies()) { + ResponseCookie httpCookie = + ResponseCookie.fromClientResponse(cookie.getName(), cookie.getValue()) + .maxAge(Duration.ofSeconds(cookie.getMaxAge())) + .domain(cookie.getDomain()) + .path(cookie.getPath()) + .secure(cookie.getSecure()) + .httpOnly(cookie.isHttpOnly()) + .build(); + clientResponse.getCookies().add(httpCookie.getName(), httpCookie); + } + byte[] bytes = servletResponse.getContentAsByteArray(); + DefaultDataBuffer dataBuffer = DefaultDataBufferFactory.sharedInstance.wrap(bytes); + clientResponse.setBody(Mono.just(dataBuffer)); + return clientResponse; + } + + + private static class MockMvcServerClientHttpResponse + extends MockClientHttpResponse implements MockServerClientHttpResponse { + + private final MvcResult mvcResult; + + + public MockMvcServerClientHttpResponse(MvcResult result) { + super(result.getResponse().getStatus()); + this.mvcResult = new PrintingMvcResult(result); + } + + @Override + public Object getServerResult() { + return this.mvcResult; + } + } + + + private static class PrintingMvcResult implements MvcResult { + + private final MvcResult mvcResult; + + public PrintingMvcResult(MvcResult mvcResult) { + this.mvcResult = mvcResult; + } + + @Override + public MockHttpServletRequest getRequest() { + return this.mvcResult.getRequest(); + } + + @Override + public MockHttpServletResponse getResponse() { + return this.mvcResult.getResponse(); + } + + @Nullable + @Override + public Object getHandler() { + return this.mvcResult.getHandler(); + } + + @Nullable + @Override + public HandlerInterceptor[] getInterceptors() { + return this.mvcResult.getInterceptors(); + } + + @Nullable + @Override + public ModelAndView getModelAndView() { + return this.mvcResult.getModelAndView(); + } + + @Nullable + @Override + public Exception getResolvedException() { + return this.mvcResult.getResolvedException(); + } + + @Override + public FlashMap getFlashMap() { + return this.mvcResult.getFlashMap(); + } + + @Override + public Object getAsyncResult() { + return this.mvcResult.getAsyncResult(); + } + + @Override + public Object getAsyncResult(long timeToWait) { + return this.mvcResult.getAsyncResult(timeToWait); + } + + @Override + public String toString() { + StringWriter writer = new StringWriter(); + try { + MockMvcResultHandlers.print(writer).handle(this); + } + catch (Exception ex) { + writer.append("Unable to format ") + .append(String.valueOf(this)) + .append(": ") + .append(ex.getMessage()); + } + return writer.toString(); + } + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcTestClient.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcTestClient.java new file mode 100644 index 00000000000..8e481389e52 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/MockMvcTestClient.java @@ -0,0 +1,380 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.servlet.client; + +import java.util.function.Supplier; + +import javax.servlet.Filter; + +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +import org.springframework.test.web.reactive.server.ExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.DispatcherServletCustomizer; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.RequestBuilder; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultHandler; +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.StandaloneMockMvcBuilder; +import org.springframework.util.Assert; +import org.springframework.validation.Validator; +import org.springframework.web.accept.ContentNegotiationManager; +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.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * The main class for testing Spring MVC applications via {@link WebTestClient} + * with {@link MockMvc} for server request handling. + * + *

Provides static factory methods and specs to initialize {@code MockMvc} + * to which the {@code WebTestClient} connects to. For example: + *

+ * WebTestClient client = MockMvcTestClient.bindToController(myController)
+ *         .controllerAdvice(myControllerAdvice)
+ *         .validator(myValidator)
+ *         .build()
+ * 
+ * + *

The client itself can also be configured. For example: + *

+ * WebTestClient client = MockMvcTestClient.bindToController(myController)
+ *         .validator(myValidator)
+ *         .configureClient()
+ *         .baseUrl("/path")
+ *         .build();
+ * 
+ * + * @author Rossen Stoyanchev + * @since 5.3 + */ +public interface MockMvcTestClient { + + /** + * Begin creating a {@link WebTestClient} by providing the {@code @Controller} + * instance(s) to handle requests with. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#standaloneSetup(Object...)}. + * to initialize {@link MockMvc}. + */ + static ControllerSpec bindToController(Object... controllers) { + return new StandaloneMockMvcSpec(controllers); + } + + /** + * Begin creating a {@link WebTestClient} by providing a + * {@link WebApplicationContext} with Spring MVC infrastructure and + * controllers. + *

Internally this is delegated to and equivalent to using + * {@link org.springframework.test.web.servlet.setup.MockMvcBuilders#webAppContextSetup(WebApplicationContext)} + * to initialize {@code MockMvc}. + */ + static MockMvcServerSpec bindToApplicationContext(WebApplicationContext context) { + return new ApplicationContextMockMvcSpec(context); + } + + /** + * Begin creating a {@link WebTestClient} by providing an already + * initialized {@link MockMvc} instance to use as the server. + */ + static WebTestClient.Builder bindTo(MockMvc mockMvc) { + ClientHttpConnector connector = new MockMvcHttpConnector(mockMvc); + return WebTestClient.bindToServer(connector); + } + + /** + * This method can be used to apply further assertions on a given + * {@link ExchangeResult} based the state of the server response. + * + *

Normally {@link WebTestClient} is used to assert the client response + * including HTTP status, headers, and body. That is all that is available + * when making a live request over HTTP. However when the server is + * {@link MockMvc}, many more assertions are possible against the server + * response, e.g. model attributes, flash attributes, etc. + * + *

Example: + *

+	 * EntityExchangeResult<Void> result =
+	 * 		webTestClient.post().uri("/people/123")
+	 * 				.exchange()
+	 * 				.expectStatus().isFound()
+	 * 				.expectHeader().location("/persons/Joe")
+	 * 				.expectBody().isEmpty();
+	 *
+	 * MockMvcTestClient.resultActionsFor(result)
+	 * 		.andExpect(model().size(1))
+	 * 		.andExpect(model().attributeExists("name"))
+	 * 		.andExpect(flash().attributeCount(1))
+	 * 		.andExpect(flash().attribute("message", "success!"));
+	 * 
+ * + *

Note: this method works only if the {@link WebTestClient} used to + * perform the request was initialized through one of bind method in this + * class, and therefore requests are handled by {@link MockMvc}. + */ + static ResultActions resultActionsFor(ExchangeResult exchangeResult) { + Object serverResult = exchangeResult.getMockServerResult(); + Assert.notNull(serverResult, "No MvcResult"); + Assert.isInstanceOf(MvcResult.class, serverResult); + return new ResultActions() { + @Override + public ResultActions andExpect(ResultMatcher matcher) throws Exception { + matcher.match((MvcResult) serverResult); + return this; + } + @Override + public ResultActions andDo(ResultHandler handler) throws Exception { + handler.handle((MvcResult) serverResult); + return this; + } + @Override + public MvcResult andReturn() { + return (MvcResult) serverResult; + } + }; + } + + + /** + * Base specification for configuring {@link MockMvc}, and a simple facade + * around {@link ConfigurableMockMvcBuilder}. + * + * @param a self reference to the builder type + */ + interface MockMvcServerSpec> { + + /** + * Add a global filter. + *

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

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

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

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

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

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

This is delegated to + * {@link ConfigurableMockMvcBuilder#apply(MockMvcConfigurer)}. + */ + T apply(MockMvcConfigurer configurer); + + /** + * Proceed to configure and build the test client. + */ + WebTestClient.Builder configureClient(); + + /** + * Shortcut to build the test client. + */ + WebTestClient build(); + } + + + /** + * Specification for configuring {@link MockMvc} to test one or more + * controllers directly, and a simple facade around + * {@link StandaloneMockMvcBuilder}. + */ + interface ControllerSpec extends MockMvcServerSpec { + + /** + * Register {@link org.springframework.web.bind.annotation.ControllerAdvice} + *

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This is delegated to + * {@link StandaloneMockMvcBuilder#setPatternParser(PathPatternParser)}. + */ + ControllerSpec patternParser(PathPatternParser parser); + + /** + * Whether to match trailing slashes. + *

This is delegated to + * {@link StandaloneMockMvcBuilder#setUseTrailingSlashPatternMatch(boolean)}. + */ + ControllerSpec useTrailingSlashPatternMatch(boolean useTrailingSlashPatternMatch); + + /** + * Configure placeholder values to use. + *

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

This is delegated to + * {@link StandaloneMockMvcBuilder#setCustomHandlerMapping(Supplier)}. + */ + ControllerSpec customHandlerMapping(Supplier factory); + } + +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java new file mode 100644 index 00000000000..42264027681 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/StandaloneMockMvcSpec.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.test.web.servlet.client; + +import java.util.function.Supplier; + +import org.springframework.format.support.FormattingConversionService; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.lang.Nullable; +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 MockMvcTestClient.ControllerSpec}. + * + * @author Rossen Stoyanchev + * @since 5.3 + */ +class StandaloneMockMvcSpec extends AbstractMockMvcServerSpec + implements MockMvcTestClient.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( + @Nullable String[] 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 useTrailingSlashPatternMatch(boolean useTrailingSlashPatternMatch) { + this.mockMvcBuilder.setUseTrailingSlashPatternMatch(useTrailingSlashPatternMatch); + return this; + } + + @Override + public StandaloneMockMvcSpec placeholderValue(String name, String value) { + this.mockMvcBuilder.addPlaceholderValue(name, value); + return this; + } + + @Override + public StandaloneMockMvcSpec customHandlerMapping(Supplier factory) { + this.mockMvcBuilder.setCustomHandlerMapping(factory); + return this; + } + + @Override + public ConfigurableMockMvcBuilder getMockMvcBuilder() { + return this.mockMvcBuilder; + } +} diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java b/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java new file mode 100644 index 00000000000..7323682c55d --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/client/package-info.java @@ -0,0 +1,13 @@ +/** + * Support for testing Spring MVC applications via + * {@link org.springframework.test.web.reactive.server.WebTestClient} + * with {@link org.springframework.test.web.servlet.MockMvc} for server request + * handling. + */ + +@NonNullApi +@NonNullFields +package org.springframework.test.web.servlet.client; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/AbstractMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/AbstractMockMvcBuilder.java index ab9b89c0aa4..de05563d704 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/AbstractMockMvcBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/AbstractMockMvcBuilder.java @@ -107,6 +107,7 @@ public abstract class AbstractMockMvcBuilder return self(); } + @Override public final T addDispatcherServletCustomizer(DispatcherServletCustomizer customizer) { this.dispatcherServletCustomizers.add(customizer); return self(); diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java index 31dbd4169b9..a619ee7a588 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/setup/ConfigurableMockMvcBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2020 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. @@ -18,6 +18,7 @@ package org.springframework.test.web.servlet.setup; import javax.servlet.Filter; +import org.springframework.test.web.servlet.DispatcherServletCustomizer; import org.springframework.test.web.servlet.MockMvcBuilder; import org.springframework.test.web.servlet.RequestBuilder; import org.springframework.test.web.servlet.ResultHandler; @@ -37,7 +38,7 @@ public interface ConfigurableMockMvcBuilder * mockMvcBuilder.addFilters(springSecurityFilterChain); *

- *

is the equivalent of the following web.xml configuration: + *

It is the equivalent of the following web.xml configuration: *

 	 * <filter-mapping>
 	 *     <filter-name>springSecurityFilterChain</filter-name>
@@ -52,9 +53,9 @@ public interface ConfigurableMockMvcBuilder
-	 * mockMvcBuilder.addFilters(myResourceFilter, "/resources/*");
+	 * mockMvcBuilder.addFilter(myResourceFilter, "/resources/*");
 	 * 
- *

is the equivalent of: + *

It is the equivalent of: *

 	 * <filter-mapping>
 	 *     <filter-name>myResourceFilter</filter-name>
@@ -105,6 +106,13 @@ public interface ConfigurableMockMvcBuilder T dispatchOptions(boolean dispatchOptions);
 
+	/**
+	 * A more advanced variant of {@link #dispatchOptions(boolean)} that allows
+	 * customizing any {@link org.springframework.web.servlet.DispatcherServlet}
+	 * property.
+	 */
+	 T addDispatcherServletCustomizer(DispatcherServletCustomizer customizer);
+
 	/**
 	 * Add a {@code MockMvcConfigurer} that automates MockMvc setup and
 	 * configures it for some specific purpose (e.g. security).
diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java
new file mode 100644
index 00000000000..426dc7b3445
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/CookieAssertionTests.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.test.web.reactive.server;
+
+import java.net.URI;
+import java.time.Duration;
+
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.MonoProcessor;
+import reactor.core.publisher.Sinks;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
+import org.springframework.mock.http.client.reactive.MockClientHttpRequest;
+import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Unit tests for {@link CookieAssertions}
+ * @author Rossen Stoyanchev
+ */
+public class CookieAssertionTests {
+
+	private final ResponseCookie cookie = ResponseCookie.from("foo", "bar")
+			.maxAge(Duration.ofMinutes(30))
+			.domain("foo.com")
+			.path("/foo")
+			.secure(true)
+			.httpOnly(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 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 sameSite() {
+		assertions.sameSite("foo", "Lax");
+		assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> assertions.sameSite("foo", "Strict"));
+	}
+
+
+	private CookieAssertions cookieAssertions(ResponseCookie cookie) {
+		MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("/"));
+		MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK);
+		response.getCookies().add(cookie.getName(), cookie);
+
+		MonoProcessor emptyContent = MonoProcessor.fromSink(Sinks.one());
+		emptyContent.onComplete();
+
+		ExchangeResult result = new ExchangeResult(request, response, emptyContent, emptyContent, Duration.ZERO, null, null);
+		return new CookieAssertions(result, mock(WebTestClient.ResponseSpec.class));
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java
index 8cec305eb36..dd5cebe838f 100644
--- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/HeaderAssertionTests.java
@@ -244,7 +244,7 @@ class HeaderAssertionTests {
 		MonoProcessor emptyContent = MonoProcessor.fromSink(Sinks.one());
 		emptyContent.onComplete();
 
-		ExchangeResult result = new ExchangeResult(request, response, emptyContent, emptyContent, Duration.ZERO, null);
+		ExchangeResult result = new ExchangeResult(request, response, emptyContent, emptyContent, Duration.ZERO, null, null);
 		return new HeaderAssertions(result, mock(WebTestClient.ResponseSpec.class));
 	}
 
diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java
index 9f06a724664..c9fd9b68d06 100644
--- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/StatusAssertionTests.java
@@ -160,7 +160,7 @@ class StatusAssertionTests {
 		MonoProcessor emptyContent = MonoProcessor.fromSink(Sinks.one());
 		emptyContent.onComplete();
 
-		ExchangeResult result = new ExchangeResult(request, response, emptyContent, emptyContent, Duration.ZERO, null);
+		ExchangeResult result = new ExchangeResult(request, response, emptyContent, emptyContent, Duration.ZERO, null, null);
 		return new StatusAssertions(result, mock(WebTestClient.ResponseSpec.class));
 	}
 
diff --git a/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java b/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java
index 60a3ba15e34..000f2f14174 100644
--- a/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/reactive/server/WiretapConnectorTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2020 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.
@@ -57,8 +57,7 @@ public class WiretapConnectorTests {
 		ExchangeFunction function = ExchangeFunctions.create(wiretapConnector);
 		function.exchange(clientRequest).block(ofMillis(0));
 
-		WiretapConnector.Info actual = wiretapConnector.claimRequest("1");
-		ExchangeResult result = actual.createExchangeResult(Duration.ZERO, null);
+		ExchangeResult result = wiretapConnector.getExchangeResult("1", null, Duration.ZERO);
 		assertThat(result.getMethod()).isEqualTo(HttpMethod.GET);
 		assertThat(result.getUrl().toString()).isEqualTo("/test");
 	}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java
new file mode 100644
index 00000000000..1908958a63c
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/AsyncControllerJavaConfigTests.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.context;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mockito;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.ContextHierarchy;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.context.request.async.CallableProcessingInterceptor;
+import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import static org.mockito.ArgumentMatchers.any;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.context.AsyncControllerJavaConfigTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+@ExtendWith(SpringExtension.class)
+@WebAppConfiguration
+@ContextHierarchy(@ContextConfiguration(classes = AsyncControllerJavaConfigTests.WebConfig.class))
+public class AsyncControllerJavaConfigTests {
+
+	@Autowired
+	private WebApplicationContext wac;
+
+	@Autowired
+	private CallableProcessingInterceptor callableInterceptor;
+
+	private WebTestClient testClient;
+
+
+	@BeforeEach
+	public void setup() {
+		this.testClient = MockMvcTestClient.bindToApplicationContext(this.wac).build();
+	}
+
+	@Test
+	public void callableInterceptor() throws Exception {
+		testClient.get().uri("/callable")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().json("{\"key\":\"value\"}");
+
+		Mockito.verify(this.callableInterceptor).beforeConcurrentHandling(any(), any());
+		Mockito.verify(this.callableInterceptor).preProcess(any(), any());
+		Mockito.verify(this.callableInterceptor).postProcess(any(), any(), any());
+		Mockito.verify(this.callableInterceptor).afterCompletion(any(), any());
+		Mockito.verifyNoMoreInteractions(this.callableInterceptor);
+	}
+
+
+	@Configuration
+	@EnableWebMvc
+	static class WebConfig implements WebMvcConfigurer {
+
+		@Override
+		public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
+			configurer.registerCallableInterceptors(callableInterceptor());
+		}
+
+		@Bean
+		public CallableProcessingInterceptor callableInterceptor() {
+			return Mockito.mock(CallableProcessingInterceptor.class);
+		}
+
+		@Bean
+		public AsyncController asyncController() {
+			return new AsyncController();
+		}
+
+	}
+
+	@RestController
+	static class AsyncController {
+
+		@GetMapping("/callable")
+		public Callable> getCallable() {
+			return () -> Collections.singletonMap("key", "value");
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java
new file mode 100644
index 00000000000..5ddd1859ad7
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/JavaConfigTests.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.context;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mockito;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.ContextHierarchy;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.test.web.servlet.samples.context.PersonController;
+import org.springframework.test.web.servlet.samples.context.PersonDao;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
+import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.view.tiles3.TilesConfigurer;
+
+import static org.mockito.BDDMockito.given;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.context.JavaConfigTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+@ExtendWith(SpringExtension.class)
+@WebAppConfiguration("classpath:META-INF/web-resources")
+@ContextHierarchy({
+	@ContextConfiguration(classes = JavaConfigTests.RootConfig.class),
+	@ContextConfiguration(classes = JavaConfigTests.WebConfig.class)
+})
+public class JavaConfigTests {
+
+	@Autowired
+	private WebApplicationContext wac;
+
+	@Autowired
+	private PersonDao personDao;
+
+	@Autowired
+	private PersonController personController;
+
+	private WebTestClient testClient;
+
+
+	@BeforeEach
+	public void setup() {
+		this.testClient = MockMvcTestClient.bindToApplicationContext(this.wac).build();
+		given(this.personDao.getPerson(5L)).willReturn(new Person("Joe"));
+	}
+
+
+	@Test
+	public void person() {
+		testClient.get().uri("/person/5")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+	@Test
+	public void tilesDefinitions() {
+		testClient.get().uri("/")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals("Forwarded-Url", "/WEB-INF/layouts/standardLayout.jsp");
+	}
+
+
+	@Configuration
+	static class RootConfig {
+
+		@Bean
+		public PersonDao personDao() {
+			return Mockito.mock(PersonDao.class);
+		}
+	}
+
+	@Configuration
+	@EnableWebMvc
+	static class WebConfig implements WebMvcConfigurer {
+
+		@Autowired
+		private RootConfig rootConfig;
+
+		@Bean
+		public PersonController personController() {
+			return new PersonController(this.rootConfig.personDao());
+		}
+
+		@Override
+		public void addResourceHandlers(ResourceHandlerRegistry registry) {
+			registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
+		}
+
+		@Override
+		public void addViewControllers(ViewControllerRegistry registry) {
+			registry.addViewController("/").setViewName("home");
+		}
+
+		@Override
+		public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
+			configurer.enable();
+		}
+
+		@Override
+		public void configureViewResolvers(ViewResolverRegistry registry) {
+			registry.tiles();
+		}
+
+		@Bean
+		public TilesConfigurer tilesConfigurer() {
+			TilesConfigurer configurer = new TilesConfigurer();
+			configurer.setDefinitions("/WEB-INF/**/tiles.xml");
+			return configurer;
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java
new file mode 100644
index 00000000000..93457f87fd1
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/WebAppResourceTests.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.context;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.ContextHierarchy;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.servlet.resource.DefaultServletHttpRequestHandler;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.handler;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.context.WebAppResourceTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+@ExtendWith(SpringExtension.class)
+@WebAppConfiguration("src/test/resources/META-INF/web-resources")
+@ContextHierarchy({
+	@ContextConfiguration("../../context/root-context.xml"),
+	@ContextConfiguration("../../context/servlet-context.xml")
+})
+public class WebAppResourceTests {
+
+	@Autowired
+	private WebApplicationContext wac;
+
+	private WebTestClient testClient;
+
+
+	@BeforeEach
+	public void setup() {
+		this.testClient = MockMvcTestClient.bindToApplicationContext(this.wac).build();
+	}
+
+	// TilesConfigurer: resources under "/WEB-INF/**/tiles.xml"
+
+	@Test
+	public void tilesDefinitions() {
+		testClient.get().uri("/")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals("Forwarded-Url", "/WEB-INF/layouts/standardLayout.jsp");
+	}
+
+	// Resources served via 
+
+	@Test
+	public void resourceRequest() {
+		testClient.get().uri("/resources/Spring.js")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType("application/javascript")
+				.expectBody(String.class).value(containsString("Spring={};"));
+	}
+
+	// Forwarded to the "default" servlet via 
+
+	@Test
+	public void resourcesViaDefaultServlet() throws Exception {
+		EntityExchangeResult result = testClient.get().uri("/unknown/resource")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().isEmpty();
+
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(handler().handlerType(DefaultServletHttpRequestHandler.class))
+				.andExpect(forwardedUrl("default"));
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java
new file mode 100644
index 00000000000..79ca42bf844
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/context/XmlConfigTests.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.context;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.ContextHierarchy;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.test.web.servlet.samples.context.PersonDao;
+import org.springframework.web.context.WebApplicationContext;
+
+import static org.mockito.BDDMockito.given;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.context.XmlConfigTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+@ExtendWith(SpringExtension.class)
+@WebAppConfiguration("src/test/resources/META-INF/web-resources")
+@ContextHierarchy({
+	@ContextConfiguration("../../context/root-context.xml"),
+	@ContextConfiguration("../../context/servlet-context.xml")
+})
+public class XmlConfigTests {
+
+	@Autowired
+	private WebApplicationContext wac;
+
+	@Autowired
+	private PersonDao personDao;
+
+	private WebTestClient testClient;
+
+
+	@BeforeEach
+	public void setup() {
+		this.testClient = MockMvcTestClient.bindToApplicationContext(this.wac).build();
+		given(this.personDao.getPerson(5L)).willReturn(new Person("Joe"));
+	}
+
+
+	@Test
+	public void person() {
+		testClient.get().uri("/person/5")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+	@Test
+	public void tilesDefinitions() {
+		testClient.get().uri("/")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals("Forwarded-Url", "/WEB-INF/layouts/standardLayout.jsp");
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java
new file mode 100644
index 00000000000..707602bfeb4
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/AsyncTests.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
+
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.util.concurrent.ListenableFuture;
+import org.springframework.util.concurrent.ListenableFutureTask;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.request.async.DeferredResult;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.AsyncTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class AsyncTests {
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new AsyncController()).build();
+
+
+	@Test
+	public void callable() {
+		this.testClient.get()
+				.uri("/1?callable=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+	@Test
+	public void streaming() {
+		this.testClient.get()
+				.uri("/1?streaming=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).isEqualTo("name=Joe");
+	}
+
+	@Test
+	public void streamingSlow() {
+		this.testClient.get()
+				.uri("/1?streamingSlow=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).isEqualTo("name=Joe&someBoolean=true");
+	}
+
+	@Test
+	public void streamingJson() {
+		this.testClient.get()
+				.uri("/1?streamingJson=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.5}");
+	}
+
+	@Test
+	public void deferredResult() {
+		this.testClient.get()
+				.uri("/1?deferredResult=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+	@Test
+	public void deferredResultWithImmediateValue() throws Exception {
+		this.testClient.get()
+				.uri("/1?deferredResultWithImmediateValue=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+	@Test
+	public void deferredResultWithDelayedError() throws Exception {
+		this.testClient.get()
+				.uri("/1?deferredResultWithDelayedError=true")
+				.exchange()
+				.expectStatus().is5xxServerError()
+				.expectBody(String.class).isEqualTo("Delayed Error");
+	}
+
+	@Test
+	public void listenableFuture() throws Exception {
+		this.testClient.get()
+				.uri("/1?listenableFuture=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+	@Test
+	public void completableFutureWithImmediateValue() throws Exception {
+		this.testClient.get()
+				.uri("/1?completableFutureWithImmediateValue=true")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().json("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+
+	@RestController
+	@RequestMapping(path = "/{id}", produces = "application/json")
+	private static class AsyncController {
+
+		@RequestMapping(params = "callable")
+		public Callable getCallable() {
+			return () -> new Person("Joe");
+		}
+
+		@RequestMapping(params = "streaming")
+		public StreamingResponseBody getStreaming() {
+			return os -> os.write("name=Joe".getBytes(StandardCharsets.UTF_8));
+		}
+
+		@RequestMapping(params = "streamingSlow")
+		public StreamingResponseBody getStreamingSlow() {
+			return os -> {
+				os.write("name=Joe".getBytes());
+				try {
+					Thread.sleep(200);
+					os.write("&someBoolean=true".getBytes(StandardCharsets.UTF_8));
+				}
+				catch (InterruptedException e) {
+					/* no-op */
+				}
+			};
+		}
+
+		@RequestMapping(params = "streamingJson")
+		public ResponseEntity getStreamingJson() {
+			return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON)
+					.body(os -> os.write("{\"name\":\"Joe\",\"someDouble\":0.5}".getBytes(StandardCharsets.UTF_8)));
+		}
+
+		@RequestMapping(params = "deferredResult")
+		public DeferredResult getDeferredResult() {
+			DeferredResult result = new DeferredResult<>();
+			delay(100, () -> result.setResult(new Person("Joe")));
+			return result;
+		}
+
+		@RequestMapping(params = "deferredResultWithImmediateValue")
+		public DeferredResult getDeferredResultWithImmediateValue() {
+			DeferredResult result = new DeferredResult<>();
+			result.setResult(new Person("Joe"));
+			return result;
+		}
+
+		@RequestMapping(params = "deferredResultWithDelayedError")
+		public DeferredResult getDeferredResultWithDelayedError() {
+			DeferredResult result = new DeferredResult<>();
+			delay(100, () -> result.setErrorResult(new RuntimeException("Delayed Error")));
+			return result;
+		}
+
+		@RequestMapping(params = "listenableFuture")
+		public ListenableFuture getListenableFuture() {
+			ListenableFutureTask futureTask = new ListenableFutureTask<>(() -> new Person("Joe"));
+			delay(100, futureTask);
+			return futureTask;
+		}
+
+		@RequestMapping(params = "completableFutureWithImmediateValue")
+		public CompletableFuture getCompletableFutureWithImmediateValue() {
+			CompletableFuture future = new CompletableFuture<>();
+			future.complete(new Person("Joe"));
+			return future;
+		}
+
+		@ExceptionHandler(Exception.class)
+		@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+		public String errorHandler(Exception ex) {
+			return ex.getMessage();
+		}
+
+		private void delay(long millis, Runnable task) {
+			Mono.delay(Duration.ofMillis(millis)).doOnTerminate(task).subscribe();
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ExceptionHandlerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ExceptionHandlerTests.java
new file mode 100644
index 00000000000..6dce2699cce
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ExceptionHandlerTests.java
@@ -0,0 +1,238 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+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.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.ExceptionHandlerTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+class ExceptionHandlerTests {
+
+	@Nested
+	class MvcTests {
+
+		@Test
+		void localExceptionHandlerMethod() {
+			WebTestClient client = MockMvcTestClient.bindToController(new PersonController()).build();
+
+			client.get().uri("/person/Clyde")
+					.exchange()
+					.expectStatus().isOk()
+					.expectHeader().valueEquals("Forwarded-Url", "errorView");
+		}
+
+		@Test
+		void globalExceptionHandlerMethod() {
+			WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+					.controllerAdvice(new GlobalExceptionHandler())
+					.build();
+
+			client.get().uri("/person/Bonnie")
+					.exchange()
+					.expectStatus().isOk()
+					.expectHeader().valueEquals("Forwarded-Url", "globalErrorView");
+		}
+	}
+
+
+	@Controller
+	private static class PersonController {
+
+		@GetMapping("/person/{name}")
+		String show(@PathVariable String name) {
+			if (name.equals("Clyde")) {
+				throw new IllegalArgumentException("simulated exception");
+			}
+			else if (name.equals("Bonnie")) {
+				throw new IllegalStateException("simulated exception");
+			}
+			return "person/show";
+		}
+
+		@ExceptionHandler
+		String handleException(IllegalArgumentException exception) {
+			return "errorView";
+		}
+	}
+
+	@ControllerAdvice
+	private static class GlobalExceptionHandler {
+
+		@ExceptionHandler
+		String handleException(IllegalStateException exception) {
+			return "globalErrorView";
+		}
+	}
+
+
+	@Nested
+	class RestTests {
+
+		@Test
+		void noException() {
+			WebTestClient client = MockMvcTestClient.bindToController(new RestPersonController())
+					.controllerAdvice(new RestPersonControllerExceptionHandler())
+					.build();
+
+			client.get().uri("/person/Yoda")
+					.accept(MediaType.APPLICATION_JSON)
+					.exchange()
+					.expectStatus().isOk()
+					.expectBody().jsonPath("$.name", "Yoda");
+		}
+
+		@Test
+		void localExceptionHandlerMethod() {
+			WebTestClient client = MockMvcTestClient.bindToController(new RestPersonController())
+					.controllerAdvice(new RestPersonControllerExceptionHandler())
+					.build();
+
+			client.get().uri("/person/Luke")
+					.accept(MediaType.APPLICATION_JSON)
+					.exchange()
+					.expectStatus().isOk()
+					.expectBody().jsonPath("$.error", "local - IllegalArgumentException");
+		}
+
+		@Test
+		void globalExceptionHandlerMethod() {
+			WebTestClient client = MockMvcTestClient.bindToController(new RestPersonController())
+					.controllerAdvice(new RestGlobalExceptionHandler())
+					.build();
+
+			client.get().uri("/person/Leia")
+					.accept(MediaType.APPLICATION_JSON)
+					.exchange()
+					.expectStatus().isOk()
+					.expectBody().jsonPath("$.error", "global - IllegalArgumentException");
+		}
+
+		@Test
+		void globalRestPersonControllerExceptionHandlerTakesPrecedenceOverGlobalExceptionHandler() {
+			WebTestClient client = MockMvcTestClient.bindToController(new RestPersonController())
+					.controllerAdvice(RestGlobalExceptionHandler.class, RestPersonControllerExceptionHandler.class)
+					.build();
+
+			client.get().uri("/person/Leia")
+					.accept(MediaType.APPLICATION_JSON)
+					.exchange()
+					.expectStatus().isOk()
+					.expectBody().jsonPath("$.error", "globalPersonController - IllegalStateException");
+		}
+
+		@Test
+		void noHandlerFound() {
+			WebTestClient client = MockMvcTestClient.bindToController(new RestPersonController())
+					.controllerAdvice(RestGlobalExceptionHandler.class, RestPersonControllerExceptionHandler.class)
+					.dispatcherServletCustomizer(servlet -> servlet.setThrowExceptionIfNoHandlerFound(true))
+					.build();
+
+			client.get().uri("/bogus")
+					.accept(MediaType.APPLICATION_JSON)
+					.exchange()
+					.expectStatus().isOk()
+					.expectBody().jsonPath("$.error", "global - NoHandlerFoundException");
+		}
+	}
+
+
+	@RestController
+	private static class RestPersonController {
+
+		@GetMapping("/person/{name}")
+		Person get(@PathVariable String name) {
+			switch (name) {
+				case "Luke":
+					throw new IllegalArgumentException();
+				case "Leia":
+					throw new IllegalStateException();
+				default:
+					return new Person("Yoda");
+			}
+		}
+
+		@ExceptionHandler
+		Error handleException(IllegalArgumentException exception) {
+			return new Error("local - " + exception.getClass().getSimpleName());
+		}
+	}
+
+	@RestControllerAdvice(assignableTypes = RestPersonController.class)
+	@Order(Ordered.HIGHEST_PRECEDENCE)
+	private static class RestPersonControllerExceptionHandler {
+
+		@ExceptionHandler
+		Error handleException(Throwable exception) {
+			return new Error("globalPersonController - " + exception.getClass().getSimpleName());
+		}
+	}
+
+	@RestControllerAdvice
+	@Order(Ordered.LOWEST_PRECEDENCE)
+	private static class RestGlobalExceptionHandler {
+
+		@ExceptionHandler
+		Error handleException(Throwable exception) {
+			return new Error( "global - " + exception.getClass().getSimpleName());
+		}
+	}
+
+	static class Person {
+
+		private final String name;
+
+		Person(String name) {
+			this.name = name;
+		}
+
+		public String getName() {
+			return name;
+		}
+	}
+
+	static class Error {
+
+		private final String error;
+
+		Error(String error) {
+			this.error = error;
+		}
+
+		public String getError() {
+			return error;
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/FilterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/FilterTests.java
new file mode 100644
index 00000000000..ea5b3a12058
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/FilterTests.java
@@ -0,0 +1,323 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import java.io.IOException;
+import java.security.Principal;
+import java.util.concurrent.CompletableFuture;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.AsyncListener;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import javax.validation.Valid;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.filter.ShallowEtagHeaderFilter;
+import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.FilterTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class FilterTests {
+
+	@Test
+	public void whenFiltersCompleteMvcProcessesRequest() throws Exception {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filters(new ContinueFilter())
+				.build();
+
+		EntityExchangeResult exchangeResult = client.post().uri("/persons?name=Andy")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().location("/person/1")
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("id"))
+				.andExpect(flash().attributeCount(1))
+				.andExpect(flash().attribute("message", "success!"));
+	}
+
+	@Test
+	public void filtersProcessRequest() {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filters(new ContinueFilter(), new RedirectFilter())
+				.build();
+
+		client.post().uri("/persons?name=Andy")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().location("/login");
+	}
+
+	@Test
+	public void filterMappedBySuffix() {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filter(new RedirectFilter(), "*.html")
+				.build();
+
+		client.post().uri("/persons.html?name=Andy")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().location("/login");
+	}
+
+	@Test
+	public void filterWithExactMapping() {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filter(new RedirectFilter(), "/p", "/persons")
+				.build();
+
+		client.post().uri("/persons?name=Andy")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().location("/login");
+	}
+
+	@Test
+	public void filterSkipped() throws Exception {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filter(new RedirectFilter(), "/p", "/person")
+				.build();
+
+		EntityExchangeResult exchangeResult =
+				client.post().uri("/persons?name=Andy")
+						.exchange()
+						.expectStatus().isFound()
+						.expectHeader().location("/person/1")
+						.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("id"))
+				.andExpect(flash().attributeCount(1))
+				.andExpect(flash().attribute("message", "success!"));
+	}
+
+	@Test
+	public void filterWrapsRequestResponse() throws Exception {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filter(new WrappingRequestResponseFilter())
+				.build();
+
+		EntityExchangeResult exchangeResult =
+				client.post().uri("/user").exchange().expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("principal", WrappingRequestResponseFilter.PRINCIPAL_NAME));
+	}
+
+	@Test
+	public void filterWrapsRequestResponseAndPerformsAsyncDispatch() {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController())
+				.filters(new WrappingRequestResponseFilter(), new ShallowEtagHeaderFilter())
+				.build();
+
+		client.get().uri("/persons/1")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentLength(53)
+				.expectHeader().valueEquals("ETag", "\"0e37becb4f0c90709cb2e1efcc61eaa00\"")
+				.expectBody().json("{\"name\":\"Lukas\",\"someDouble\":0.0,\"someBoolean\":false}");
+	}
+
+
+	@Controller
+	private static class PersonController {
+
+		@PostMapping(path="/persons")
+		public String save(@Valid Person person, Errors errors, RedirectAttributes redirectAttrs) {
+			if (errors.hasErrors()) {
+				return "person/add";
+			}
+			redirectAttrs.addAttribute("id", "1");
+			redirectAttrs.addFlashAttribute("message", "success!");
+			return "redirect:/person/{id}";
+		}
+
+		@PostMapping("/user")
+		public ModelAndView user(Principal principal) {
+			return new ModelAndView("user/view", "principal", principal.getName());
+		}
+
+		@GetMapping("/forward")
+		public String forward() {
+			return "forward:/persons";
+		}
+
+		@GetMapping("persons/{id}")
+		@ResponseBody
+		public CompletableFuture getPerson() {
+			return CompletableFuture.completedFuture(new Person("Lukas"));
+		}
+	}
+
+	private class ContinueFilter extends OncePerRequestFilter {
+
+		@Override
+		protected void doFilterInternal(HttpServletRequest request,
+				HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+
+			filterChain.doFilter(request, response);
+		}
+	}
+
+	private static class WrappingRequestResponseFilter extends OncePerRequestFilter {
+
+		public static final String PRINCIPAL_NAME = "WrapRequestResponseFilterPrincipal";
+
+
+		@Override
+		protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+				FilterChain filterChain) throws ServletException, IOException {
+
+			filterChain.doFilter(new HttpServletRequestWrapper(request) {
+
+				@Override
+				public Principal getUserPrincipal() {
+					return () -> PRINCIPAL_NAME;
+				}
+
+				// Like Spring Security does in HttpServlet3RequestFactory..
+
+				@Override
+				public AsyncContext getAsyncContext() {
+					return super.getAsyncContext() != null ?
+							new AsyncContextWrapper(super.getAsyncContext()) : null;
+				}
+
+			}, new HttpServletResponseWrapper(response));
+		}
+	}
+
+	private class RedirectFilter extends OncePerRequestFilter {
+
+		@Override
+		protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+				FilterChain filterChain) throws ServletException, IOException {
+
+			response.sendRedirect("/login");
+		}
+	}
+
+
+	private static class AsyncContextWrapper implements AsyncContext {
+
+		private final AsyncContext delegate;
+
+		public AsyncContextWrapper(AsyncContext delegate) {
+			this.delegate = delegate;
+		}
+
+		@Override
+		public ServletRequest getRequest() {
+			return this.delegate.getRequest();
+		}
+
+		@Override
+		public ServletResponse getResponse() {
+			return this.delegate.getResponse();
+		}
+
+		@Override
+		public boolean hasOriginalRequestAndResponse() {
+			return this.delegate.hasOriginalRequestAndResponse();
+		}
+
+		@Override
+		public void dispatch() {
+			this.delegate.dispatch();
+		}
+
+		@Override
+		public void dispatch(String path) {
+			this.delegate.dispatch(path);
+		}
+
+		@Override
+		public void dispatch(ServletContext context, String path) {
+			this.delegate.dispatch(context, path);
+		}
+
+		@Override
+		public void complete() {
+			this.delegate.complete();
+		}
+
+		@Override
+		public void start(Runnable run) {
+			this.delegate.start(run);
+		}
+
+		@Override
+		public void addListener(AsyncListener listener) {
+			this.delegate.addListener(listener);
+		}
+
+		@Override
+		public void addListener(AsyncListener listener, ServletRequest req, ServletResponse res) {
+			this.delegate.addListener(listener, req, res);
+		}
+
+		@Override
+		public  T createListener(Class clazz) throws ServletException {
+			return this.delegate.createListener(clazz);
+		}
+
+		@Override
+		public void setTimeout(long timeout) {
+			this.delegate.setTimeout(timeout);
+		}
+
+		@Override
+		public long getTimeout() {
+			return this.delegate.getTimeout();
+		}
+	}
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/FrameworkExtensionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/FrameworkExtensionTests.java
new file mode 100644
index 00000000000..71dc5592376
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/FrameworkExtensionTests.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import java.security.Principal;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcConfigurerAdapter;
+import org.springframework.util.Assert;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.context.WebApplicationContext;
+
+import static org.mockito.Mockito.mock;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.FrameworkExtensionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class FrameworkExtensionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new SampleController())
+					.apply(defaultSetup())
+					.build();
+
+
+	@Test
+	public void fooHeader() {
+		this.client.get().uri("/")
+				.header("Foo", "a=b")
+				.exchange()
+				.expectBody(String.class).isEqualTo("Foo");
+	}
+
+	@Test
+	public void barHeader() {
+		this.client.get().uri("/")
+				.header("Bar", "a=b")
+				.exchange()
+				.expectBody(String.class).isEqualTo("Bar");
+	}
+
+	private static TestMockMvcConfigurer defaultSetup() {
+		return new TestMockMvcConfigurer();
+	}
+
+
+	/**
+	 * Test {@code MockMvcConfigurer}.
+	 */
+	private static class TestMockMvcConfigurer extends MockMvcConfigurerAdapter {
+
+		@Override
+		public void afterConfigurerAdded(ConfigurableMockMvcBuilder builder) {
+			builder.alwaysExpect(status().isOk());
+		}
+
+		@Override
+		public RequestPostProcessor beforeMockMvcCreated(ConfigurableMockMvcBuilder builder,
+				WebApplicationContext context) {
+			return request -> {
+				request.setUserPrincipal(mock(Principal.class));
+				return request;
+			};
+		}
+	}
+
+
+	@Controller
+	@RequestMapping("/")
+	private static class SampleController {
+
+		@RequestMapping(headers = "Foo")
+		@ResponseBody
+		public String handleFoo(Principal principal) {
+			Assert.notNull(principal, "Principal must not be null");
+			return "Foo";
+		}
+
+		@RequestMapping(headers = "Bar")
+		@ResponseBody
+		public String handleBar(Principal principal) {
+			Assert.notNull(principal, "Principal must not be null");
+			return "Bar";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java
new file mode 100644
index 00000000000..a2dbc15dfef
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/MultipartControllerTests.java
@@ -0,0 +1,405 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.Part;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.client.MultipartBodyBuilder;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.multipart.MultipartFile;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.MultipartControllerTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class MultipartControllerTests {
+
+	private final WebTestClient testClient = MockMvcTestClient.bindToController(new MultipartController()).build();
+
+
+	@Test
+	public void multipartRequestWithSingleFile() throws Exception {
+
+		byte[] fileContent = "bar".getBytes(StandardCharsets.UTF_8);
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/multipartfile")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("fileContent", fileContent))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithSingleFileNotPresent() {
+		testClient.post().uri("/multipartfile")
+				.exchange()
+				.expectStatus().isFound();
+	}
+
+	@Test
+	public void multipartRequestWithFileArray() throws Exception {
+		byte[] fileContent = "bar".getBytes(StandardCharsets.UTF_8);
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/multipartfilearray")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("fileContent", fileContent))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithFileArrayNoMultipart() {
+		testClient.post().uri("/multipartfilearray")
+				.exchange()
+				.expectStatus().isFound();
+	}
+
+	@Test
+	public void multipartRequestWithOptionalFile() throws Exception {
+		byte[] fileContent = "bar".getBytes(StandardCharsets.UTF_8);
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/optionalfile")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("fileContent", fileContent))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithOptionalFileNotPresent() throws Exception {
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/optionalfile")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attributeDoesNotExist("fileContent"))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithOptionalFileArray() throws Exception {
+		byte[] fileContent = "bar".getBytes(StandardCharsets.UTF_8);
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/optionalfilearray")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("fileContent", fileContent))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithOptionalFileArrayNotPresent() throws Exception {
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/optionalfilearray")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attributeDoesNotExist("fileContent"))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithOptionalFileList() throws Exception {
+		byte[] fileContent = "bar".getBytes(StandardCharsets.UTF_8);
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/optionalfilelist")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("fileContent", fileContent))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithOptionalFileListNotPresent() throws Exception {
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/optionalfilelist")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attributeDoesNotExist("fileContent"))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWithServletParts() throws Exception {
+		byte[] fileContent = "bar".getBytes(StandardCharsets.UTF_8);
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("file", fileContent).filename("orig");
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		EntityExchangeResult exchangeResult = testClient.post().uri("/multipartfile")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("fileContent", fileContent))
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+	@Test
+	public void multipartRequestWrapped() throws Exception {
+		Map json = Collections.singletonMap("name", "yeeeah");
+
+		MultipartBodyBuilder bodyBuilder = new MultipartBodyBuilder();
+		bodyBuilder.part("json", json, MediaType.APPLICATION_JSON);
+
+		WebTestClient client = MockMvcTestClient.bindToController(new MultipartController())
+				.filter(new RequestWrappingFilter())
+				.build();
+
+		EntityExchangeResult exchangeResult = client.post().uri("/multipartfile")
+				.bodyValue(bodyBuilder.build())
+				.exchange()
+				.expectStatus().isFound()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().attribute("jsonContent", json));
+	}
+
+
+	@Controller
+	private static class MultipartController {
+
+		@RequestMapping(value = "/multipartfile", method = RequestMethod.POST)
+		public String processMultipartFile(@RequestParam(required = false) MultipartFile file,
+				@RequestPart(required = false) Map json, Model model) throws IOException {
+
+			if (file != null) {
+				model.addAttribute("fileContent", file.getBytes());
+			}
+			if (json != null) {
+				model.addAttribute("jsonContent", json);
+			}
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/multipartfilearray", method = RequestMethod.POST)
+		public String processMultipartFileArray(@RequestParam(required = false) MultipartFile[] file,
+				@RequestPart(required = false) Map json, Model model) throws IOException {
+
+			if (file != null && file.length > 0) {
+				byte[] content = file[0].getBytes();
+				assertThat(file[1].getBytes()).isEqualTo(content);
+				model.addAttribute("fileContent", content);
+			}
+			if (json != null) {
+				model.addAttribute("jsonContent", json);
+			}
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/multipartfilelist", method = RequestMethod.POST)
+		public String processMultipartFileList(@RequestParam(required = false) List file,
+				@RequestPart(required = false) Map json, Model model) throws IOException {
+
+			if (file != null && !file.isEmpty()) {
+				byte[] content = file.get(0).getBytes();
+				assertThat(file.get(1).getBytes()).isEqualTo(content);
+				model.addAttribute("fileContent", content);
+			}
+			if (json != null) {
+				model.addAttribute("jsonContent", json);
+			}
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/optionalfile", method = RequestMethod.POST)
+		public String processOptionalFile(@RequestParam Optional file,
+				@RequestPart Map json, Model model) throws IOException {
+
+			if (file.isPresent()) {
+				model.addAttribute("fileContent", file.get().getBytes());
+			}
+			model.addAttribute("jsonContent", json);
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/optionalfilearray", method = RequestMethod.POST)
+		public String processOptionalFileArray(@RequestParam Optional file,
+				@RequestPart Map json, Model model) throws IOException {
+
+			if (file.isPresent()) {
+				byte[] content = file.get()[0].getBytes();
+				assertThat(file.get()[1].getBytes()).isEqualTo(content);
+				model.addAttribute("fileContent", content);
+			}
+			model.addAttribute("jsonContent", json);
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/optionalfilelist", method = RequestMethod.POST)
+		public String processOptionalFileList(@RequestParam Optional> file,
+				@RequestPart Map json, Model model) throws IOException {
+
+			if (file.isPresent()) {
+				byte[] content = file.get().get(0).getBytes();
+				assertThat(file.get().get(1).getBytes()).isEqualTo(content);
+				model.addAttribute("fileContent", content);
+			}
+			model.addAttribute("jsonContent", json);
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/part", method = RequestMethod.POST)
+		public String processPart(@RequestParam Part part,
+				@RequestPart Map json, Model model) throws IOException {
+
+			model.addAttribute("fileContent", part.getInputStream());
+			model.addAttribute("jsonContent", json);
+
+			return "redirect:/index";
+		}
+
+		@RequestMapping(value = "/json", method = RequestMethod.POST)
+		public String processMultipart(@RequestPart Map json, Model model) {
+			model.addAttribute("json", json);
+			return "redirect:/index";
+		}
+	}
+
+
+	private static class RequestWrappingFilter extends OncePerRequestFilter {
+
+		@Override
+		protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+				FilterChain filterChain) throws IOException, ServletException {
+
+			request = new HttpServletRequestWrapper(request);
+			filterChain.doFilter(request, response);
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ReactiveReturnTypeTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ReactiveReturnTypeTests.java
new file mode 100644
index 00000000000..bf6549fd269
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ReactiveReturnTypeTests.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import java.time.Duration;
+
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.test.StepVerifier;
+
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.springframework.http.MediaType.TEXT_EVENT_STREAM;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.ReactiveReturnTypeTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class ReactiveReturnTypeTests {
+
+	@Test
+	public void sseWithFlux() {
+
+		WebTestClient testClient =
+				MockMvcTestClient.bindToController(new ReactiveController()).build();
+
+		Flux bodyFlux = testClient.get().uri("/spr16869")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentTypeCompatibleWith(TEXT_EVENT_STREAM)
+				.returnResult(String.class)
+				.getResponseBody();
+
+		StepVerifier.create(bodyFlux)
+				.expectNext("event0")
+				.expectNext("event1")
+				.expectNext("event2")
+				.verifyComplete();
+	}
+
+
+	@RestController
+	static class ReactiveController {
+
+		@GetMapping(path = "/spr16869", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+		Flux sseFlux() {
+			return Flux.interval(Duration.ofSeconds(1)).take(3)
+					.map(aLong -> String.format("event%d", aLong));
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RedirectTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RedirectTests.java
new file mode 100644
index 00000000000..2e8c1981a8d
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RedirectTests.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import javax.validation.Valid;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
+import org.springframework.ui.Model;
+import org.springframework.validation.Errors;
+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.servlet.mvc.support.RedirectAttributes;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.RedirectTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class RedirectTests {
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new PersonController()).build();
+
+
+	@Test
+	public void save() throws Exception {
+		EntityExchangeResult exchangeResult =
+				testClient.post().uri("/persons?name=Andy")
+						.exchange()
+						.expectStatus().isFound()
+						.expectHeader().location("/persons/Joe")
+						.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(exchangeResult)
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("name"))
+				.andExpect(flash().attributeCount(1))
+				.andExpect(flash().attribute("message", "success!"));
+	}
+
+	@Test
+	public void saveSpecial() throws Exception {
+		EntityExchangeResult result =
+				testClient.post().uri("/people?name=Andy")
+						.exchange()
+						.expectStatus().isFound()
+						.expectHeader().location("/persons/Joe")
+						.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("name"))
+				.andExpect(flash().attributeCount(1))
+				.andExpect(flash().attribute("message", "success!"));
+	}
+
+	@Test
+	public void saveWithErrors() throws Exception {
+		EntityExchangeResult result =
+				testClient.post().uri("/persons").exchange().expectStatus().isOk().expectBody().isEmpty();
+
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(forwardedUrl("persons/add"))
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("person"))
+				.andExpect(flash().attributeCount(0));
+	}
+
+	@Test
+	public void saveSpecialWithErrors() throws Exception {
+		EntityExchangeResult result =
+				testClient.post().uri("/people").exchange().expectStatus().isOk().expectBody().isEmpty();
+
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(forwardedUrl("persons/add"))
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("person"))
+				.andExpect(flash().attributeCount(0));
+	}
+
+	@Test
+	public void getPerson() throws Exception {
+		EntityExchangeResult result =
+				MockMvcTestClient.bindToController(new PersonController())
+						.defaultRequest(get("/").flashAttr("message", "success!"))
+						.build()
+						.get().uri("/persons/Joe")
+						.exchange()
+						.expectStatus().isOk()
+						.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(result)
+				.andDo(MockMvcResultHandlers.print())
+				.andExpect(forwardedUrl("persons/index"))
+				.andExpect(model().size(2))
+				.andExpect(model().attribute("person", new Person("Joe")))
+				.andExpect(model().attribute("message", "success!"))
+				.andExpect(flash().attributeCount(0));
+	}
+
+
+	@Controller
+	private static class PersonController {
+
+		@GetMapping("/persons/{name}")
+		public String getPerson(@PathVariable String name, Model model) {
+			model.addAttribute(new Person(name));
+			return "persons/index";
+		}
+
+		@PostMapping("/persons")
+		public String save(@Valid Person person, Errors errors, RedirectAttributes redirectAttrs) {
+			if (errors.hasErrors()) {
+				return "persons/add";
+			}
+			redirectAttrs.addAttribute("name", "Joe");
+			redirectAttrs.addFlashAttribute("message", "success!");
+			return "redirect:/persons/{name}";
+		}
+
+		@PostMapping("/people")
+		public Object saveSpecial(@Valid Person person, Errors errors, RedirectAttributes redirectAttrs) {
+			if (errors.hasErrors()) {
+				return "persons/add";
+			}
+			redirectAttrs.addAttribute("name", "Joe");
+			redirectAttrs.addFlashAttribute("message", "success!");
+			return new StringBuilder("redirect:").append("/persons").append("/{name}");
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RequestParameterTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RequestParameterTests.java
new file mode 100644
index 00000000000..fb706063ea0
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/RequestParameterTests.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.RequestParameterTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class RequestParameterTests {
+
+	@Test
+	public void queryParameter() {
+
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController()).build();
+
+		client.get().uri("/search?name=George")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody().jsonPath("$.name", "George");
+	}
+
+
+	@Controller
+	private class PersonController {
+
+		@RequestMapping(value="/search")
+		@ResponseBody
+		public Person get(@RequestParam String name) {
+			Person person = new Person(name);
+			return person;
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java
new file mode 100644
index 00000000000..2034d3c8de8
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ResponseBodyTests.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import javax.validation.constraints.NotNull;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.ResponseBodyTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class ResponseBodyTests {
+
+	@Test
+	void json() {
+		MockMvcTestClient.bindToController(new PersonController()).build()
+				.get()
+				.uri("/person/Lee")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_JSON)
+				.expectBody()
+				.jsonPath("$.name").isEqualTo("Lee")
+				.jsonPath("$.age").isEqualTo(42)
+				.jsonPath("$.age").value(equalTo(42))
+				.jsonPath("$.age").value(equalTo(42.0f), Float.class);
+	}
+
+
+	@RestController
+	private static class PersonController {
+
+		@GetMapping("/person/{name}")
+		public Person get(@PathVariable String name) {
+			Person person = new Person(name);
+			person.setAge(42);
+			return person;
+		}
+	}
+
+	private static class Person {
+
+		@NotNull
+		private final String name;
+
+		private int age;
+
+		public Person(String name) {
+			this.name = name;
+		}
+
+		public String getName() {
+			return this.name;
+		}
+
+		public int getAge() {
+			return this.age;
+		}
+
+		public void setAge(int age) {
+			this.age = age;
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java
new file mode 100644
index 00000000000..91fa05eb5f4
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.oxm.jaxb.Jaxb2Marshaller;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.ui.Model;
+import org.springframework.web.accept.ContentNegotiationManager;
+import org.springframework.web.accept.FixedContentNegotiationStrategy;
+import org.springframework.web.accept.HeaderContentNegotiationStrategy;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.servlet.View;
+import org.springframework.web.servlet.view.ContentNegotiatingViewResolver;
+import org.springframework.web.servlet.view.InternalResourceViewResolver;
+import org.springframework.web.servlet.view.json.MappingJackson2JsonView;
+import org.springframework.web.servlet.view.xml.MarshallingView;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasProperty;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.RequestParameterTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+class ViewResolutionTests {
+
+	@Test
+	void jspOnly() throws Exception {
+		WebTestClient testClient =
+				MockMvcTestClient.bindToController(new PersonController())
+						.viewResolvers(new InternalResourceViewResolver("/WEB-INF/", ".jsp"))
+						.build();
+
+		EntityExchangeResult result = testClient.get().uri("/person/Corea")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(status().isOk())
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("person"))
+				.andExpect(forwardedUrl("/WEB-INF/person/show.jsp"));
+	}
+
+	@Test
+	void jsonOnly() {
+		WebTestClient testClient =
+				MockMvcTestClient.bindToController(new PersonController())
+						.singleView(new MappingJackson2JsonView())
+						.build();
+
+		testClient.get().uri("/person/Corea")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
+				.expectBody().jsonPath("$.person.name", "Corea");
+	}
+
+	@Test
+	void xmlOnly() {
+		Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
+		marshaller.setClassesToBeBound(Person.class);
+
+		WebTestClient testClient =
+				MockMvcTestClient.bindToController(new PersonController())
+						.singleView(new MarshallingView(marshaller))
+						.build();
+
+		testClient.get().uri("/person/Corea")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_XML)
+				.expectBody().xpath("/person/name/text()").isEqualTo("Corea");
+	}
+
+	@Test
+	void contentNegotiation() throws Exception {
+		Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
+		marshaller.setClassesToBeBound(Person.class);
+
+		List viewList = new ArrayList<>();
+		viewList.add(new MappingJackson2JsonView());
+		viewList.add(new MarshallingView(marshaller));
+
+		ContentNegotiationManager manager = new ContentNegotiationManager(
+				new HeaderContentNegotiationStrategy(), new FixedContentNegotiationStrategy(MediaType.TEXT_HTML));
+
+		ContentNegotiatingViewResolver cnViewResolver = new ContentNegotiatingViewResolver();
+		cnViewResolver.setDefaultViews(viewList);
+		cnViewResolver.setContentNegotiationManager(manager);
+		cnViewResolver.afterPropertiesSet();
+
+		WebTestClient testClient =
+				MockMvcTestClient.bindToController(new PersonController())
+						.viewResolvers(cnViewResolver, new InternalResourceViewResolver())
+						.build();
+
+		EntityExchangeResult result = testClient.get().uri("/person/Corea")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(model().size(1))
+				.andExpect(model().attributeExists("person"))
+				.andExpect(forwardedUrl("person/show"));
+
+		testClient.get().uri("/person/Corea")
+				.accept(MediaType.APPLICATION_JSON)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
+				.expectBody().jsonPath("$.person.name", "Corea");
+
+		testClient.get().uri("/person/Corea")
+				.accept(MediaType.APPLICATION_XML)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.APPLICATION_XML)
+				.expectBody().xpath("/person/name/text()").isEqualTo("Corea");
+	}
+
+	@Test
+	void defaultViewResolver() throws Exception {
+		WebTestClient client = MockMvcTestClient.bindToController(new PersonController()).build();
+
+		EntityExchangeResult result = client.get().uri("/person/Corea")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().isEmpty();
+
+		// Further assertions on the server response
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(model().attribute("person", hasProperty("name", equalTo("Corea"))))
+				.andExpect(forwardedUrl("person/show"));  // InternalResourceViewResolver
+	}
+
+
+	@Controller
+	private static class PersonController {
+
+		@GetMapping("/person/{name}")
+		String show(@PathVariable String name, Model model) {
+			Person person = new Person(name);
+			model.addAttribute(person);
+			return "person/show";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java
new file mode 100644
index 00000000000..2fda9eac8a6
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resulthandlers;
+
+import java.nio.charset.StandardCharsets;
+
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resulthandlers.PrintingResultHandlerSmokeTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+@Disabled
+public class PrintingResultHandlerSmokeTests {
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new SimpleController()).build();
+
+
+	// Not intended to be executed with the build.
+	// Comment out class-level @Disabled to see the output.
+
+	@Test
+	public void printViaConsumer() {
+		testClient.post().uri("/")
+				.contentType(MediaType.TEXT_PLAIN)
+				.bodyValue("Hello Request".getBytes(StandardCharsets.UTF_8))
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class)
+				.consumeWith(System.out::println);
+	}
+
+	@Test
+	public void returnResultAndPrint() {
+		EntityExchangeResult result = testClient.post().uri("/")
+				.contentType(MediaType.TEXT_PLAIN)
+				.bodyValue("Hello Request".getBytes(StandardCharsets.UTF_8))
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class)
+				.returnResult();
+
+		System.out.println(result);
+	}
+
+
+	@RestController
+	private static class SimpleController {
+
+		@PostMapping("/")
+		public String hello(HttpServletResponse response) {
+			response.addCookie(new Cookie("enigma", "42"));
+			return "Hello Response";
+		}
+	}
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ContentAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ContentAssertionTests.java
new file mode 100644
index 00000000000..0f1480d596b
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ContentAssertionTests.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.ContentAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class ContentAssertionTests {
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new SimpleController()).build();
+
+	@Test
+	public void testContentType() {
+		testClient.get().uri("/handle").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.valueOf("text/plain;charset=ISO-8859-1"))
+				.expectHeader().contentType("text/plain;charset=ISO-8859-1")
+				.expectHeader().contentTypeCompatibleWith("text/plain")
+				.expectHeader().contentTypeCompatibleWith(MediaType.TEXT_PLAIN);
+
+		testClient.get().uri("/handleUtf8")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType(MediaType.valueOf("text/plain;charset=UTF-8"))
+				.expectHeader().contentType("text/plain;charset=UTF-8")
+				.expectHeader().contentTypeCompatibleWith("text/plain")
+				.expectHeader().contentTypeCompatibleWith(MediaType.TEXT_PLAIN);
+	}
+
+	@Test
+	public void testContentAsString() {
+
+		testClient.get().uri("/handle").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).isEqualTo("Hello world!");
+
+		testClient.get().uri("/handleUtf8").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).isEqualTo("\u3053\u3093\u306b\u3061\u306f\u4e16\u754c\uff01");
+
+		// Hamcrest matchers...
+		testClient.get().uri("/handle").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).value(equalTo("Hello world!"));
+		testClient.get().uri("/handleUtf8")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).value(equalTo("\u3053\u3093\u306b\u3061\u306f\u4e16\u754c\uff01"));
+	}
+
+	@Test
+	public void testContentAsBytes() {
+
+		testClient.get().uri("/handle").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(byte[].class).isEqualTo(
+				"Hello world!".getBytes(StandardCharsets.ISO_8859_1));
+
+		testClient.get().uri("/handleUtf8")
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(byte[].class).isEqualTo(
+				"\u3053\u3093\u306b\u3061\u306f\u4e16\u754c\uff01".getBytes(StandardCharsets.UTF_8));
+	}
+
+	@Test
+	public void testContentStringMatcher() {
+		testClient.get().uri("/handle").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody(String.class).value(containsString("world"));
+	}
+
+	@Test
+	public void testCharacterEncoding() {
+
+		testClient.get().uri("/handle").accept(MediaType.TEXT_PLAIN)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType("text/plain;charset=ISO-8859-1")
+				.expectBody(String.class).value(containsString("world"));
+
+		testClient.get().uri("/handleUtf8")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().contentType("text/plain;charset=UTF-8")
+				.expectBody(byte[].class)
+				.isEqualTo("\u3053\u3093\u306b\u3061\u306f\u4e16\u754c\uff01".getBytes(StandardCharsets.UTF_8));
+	}
+
+
+	@Controller
+	private static class SimpleController {
+
+		@RequestMapping(value="/handle", produces="text/plain")
+		@ResponseBody
+		public String handle() {
+			return "Hello world!";
+		}
+
+		@RequestMapping(value="/handleUtf8", produces="text/plain;charset=UTF-8")
+		@ResponseBody
+		public String handleWithCharset() {
+			return "\u3053\u3093\u306b\u3061\u306f\u4e16\u754c\uff01";	// "Hello world! (Japanese)
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/CookieAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/CookieAssertionTests.java
new file mode 100644
index 00000000000..5aa5cb27155
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/CookieAssertionTests.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.time.Duration;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.servlet.i18n.CookieLocaleResolver;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.startsWith;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.CookieAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class CookieAssertionTests {
+
+	private static final String COOKIE_NAME = CookieLocaleResolver.DEFAULT_COOKIE_NAME;
+
+	private WebTestClient client;
+
+
+	@BeforeEach
+	public void setup() {
+		CookieLocaleResolver localeResolver = new CookieLocaleResolver();
+		localeResolver.setCookieDomain("domain");
+		localeResolver.setCookieHttpOnly(true);
+
+		client = MockMvcTestClient.bindToController(new SimpleController())
+				.interceptors(new LocaleChangeInterceptor())
+				.localeResolver(localeResolver)
+				.alwaysExpect(status().isOk())
+				.configureClient()
+				.baseUrl("/?locale=en_US")
+				.build();
+	}
+
+
+	@Test
+	public void testExists() {
+		client.get().uri("/").exchange().expectCookie().exists(COOKIE_NAME);
+	}
+
+	@Test
+	public void testNotExists() {
+		client.get().uri("/").exchange().expectCookie().doesNotExist("unknownCookie");
+	}
+
+	@Test
+	public void testEqualTo() {
+		client.get().uri("/").exchange().expectCookie().valueEquals(COOKIE_NAME, "en-US");
+		client.get().uri("/").exchange().expectCookie().value(COOKIE_NAME, equalTo("en-US"));
+	}
+
+	@Test
+	public void testMatcher() {
+		client.get().uri("/").exchange().expectCookie().value(COOKIE_NAME, startsWith("en-US"));
+	}
+
+	@Test
+	public void testMaxAge() {
+		client.get().uri("/").exchange().expectCookie().maxAge(COOKIE_NAME, Duration.ofSeconds(-1));
+	}
+
+	@Test
+	public void testDomain() {
+		client.get().uri("/").exchange().expectCookie().domain(COOKIE_NAME, "domain");
+	}
+
+	@Test
+	public void testPath() {
+		client.get().uri("/").exchange().expectCookie().path(COOKIE_NAME, "/");
+	}
+
+	@Test
+	public void testSecured() {
+		client.get().uri("/").exchange().expectCookie().secure(COOKIE_NAME, false);
+	}
+
+	@Test
+	public void testHttpOnly() {
+		client.get().uri("/").exchange().expectCookie().httpOnly(COOKIE_NAME, true);
+	}
+
+
+	@Controller
+	private static class SimpleController {
+
+		@RequestMapping("/")
+		public String home() {
+			return "home";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/FlashAttributeAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/FlashAttributeAssertionTests.java
new file mode 100644
index 00000000000..640de972a5a
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/FlashAttributeAssertionTests.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.net.URL;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.hamcrest.Matchers.closeTo;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.FlashAttributeAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class FlashAttributeAssertionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new PersonController())
+					.alwaysExpect(status().isFound())
+					.alwaysExpect(flash().attributeCount(3))
+					.build();
+
+
+	@Test
+	void attributeCountWithWrongCount() {
+		assertThatExceptionOfType(AssertionError.class)
+			.isThrownBy(() -> performRequest().andExpect(flash().attributeCount(1)))
+			.withMessage("FlashMap size expected:<1> but was:<3>");
+	}
+
+	@Test
+	void attributeExists() throws Exception {
+		performRequest().andExpect(flash().attributeExists("one", "two", "three"));
+	}
+
+	@Test
+	void attributeEqualTo() throws Exception {
+		performRequest()
+				.andExpect(flash().attribute("one", "1"))
+				.andExpect(flash().attribute("two", 2.222))
+				.andExpect(flash().attribute("three", new URL("https://example.com")));
+	}
+
+	@Test
+	void attributeMatchers() throws Exception {
+		performRequest()
+				.andExpect(flash().attribute("one", containsString("1")))
+				.andExpect(flash().attribute("two", closeTo(2, 0.5)))
+				.andExpect(flash().attribute("three", notNullValue()))
+				.andExpect(flash().attribute("one", equalTo("1")))
+				.andExpect(flash().attribute("two", equalTo(2.222)))
+				.andExpect(flash().attribute("three", equalTo(new URL("https://example.com"))));
+	}
+
+	private ResultActions performRequest() {
+		EntityExchangeResult result = client.post().uri("/persons").exchange().expectBody().isEmpty();
+		return MockMvcTestClient.resultActionsFor(result);
+	}
+
+
+	@Controller
+	private static class PersonController {
+
+		@PostMapping("/persons")
+		String save(RedirectAttributes redirectAttrs) throws Exception {
+			redirectAttrs.addFlashAttribute("one", "1");
+			redirectAttrs.addFlashAttribute("two", 2.222);
+			redirectAttrs.addFlashAttribute("three", new URL("https://example.com"));
+			return "redirect:/person/1";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HandlerAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HandlerAssertionTests.java
new file mode 100644
index 00000000000..61c0e41f55e
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HandlerAssertionTests.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.lang.reflect.Method;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.handler;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder.on;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.HandlerAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class HandlerAssertionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new SimpleController())
+					.alwaysExpect(status().isOk())
+					.build();
+
+
+	@Test
+	public void handlerType() throws Exception {
+		performRequest().andExpect(handler().handlerType(SimpleController.class));
+	}
+
+	@Test
+	public void methodCallOnNonMock() {
+		assertThatExceptionOfType(AssertionError.class)
+				.isThrownBy(() -> performRequest().andExpect(handler().methodCall("bogus")))
+				.withMessageContaining("The supplied object [bogus] is not an instance of")
+				.withMessageContaining(MvcUriComponentsBuilder.MethodInvocationInfo.class.getName())
+				.withMessageContaining("Ensure that you invoke the handler method via MvcUriComponentsBuilder.on()");
+	}
+
+	@Test
+	public void methodCall() throws Exception {
+		performRequest().andExpect(handler().methodCall(on(SimpleController.class).handle()));
+	}
+
+	@Test
+	public void methodName() throws Exception {
+		performRequest().andExpect(handler().methodName("handle"));
+	}
+
+	@Test
+	public void methodNameMatchers() throws Exception {
+		performRequest()
+				.andExpect(handler().methodName(equalTo("handle")))
+				.andExpect(handler().methodName(is(not("save"))));
+	}
+
+	@Test
+	public void method() throws Exception {
+		Method method = SimpleController.class.getMethod("handle");
+		performRequest().andExpect(handler().method(method));
+	}
+
+	private ResultActions performRequest() {
+		EntityExchangeResult result = client.get().uri("/").exchange().expectBody().isEmpty();
+		return MockMvcTestClient.resultActionsFor(result);
+	}
+
+
+	@RestController
+	static class SimpleController {
+
+		@RequestMapping("/")
+		public ResponseEntity handle() {
+			return ResponseEntity.ok().build();
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java
new file mode 100644
index 00000000000..20c736b6f3d
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/HeaderAssertionTests.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.context.request.WebRequest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.fail;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItems;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.startsWith;
+import static org.springframework.http.HttpHeaders.IF_MODIFIED_SINCE;
+import static org.springframework.http.HttpHeaders.LAST_MODIFIED;
+import static org.springframework.http.HttpHeaders.VARY;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.HeaderAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class HeaderAssertionTests {
+
+	private static final String ERROR_MESSAGE = "Should have thrown an AssertionError";
+
+
+	private String now;
+
+	private String minuteAgo;
+
+	private WebTestClient testClient;
+
+	private final long currentTime = System.currentTimeMillis();
+
+	private SimpleDateFormat dateFormat;
+
+
+	@BeforeEach
+	public void setup() {
+		this.dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
+		this.dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
+		this.now = dateFormat.format(new Date(this.currentTime));
+		this.minuteAgo = dateFormat.format(new Date(this.currentTime - (1000 * 60)));
+
+		PersonController controller = new PersonController();
+		controller.setStubTimestamp(this.currentTime);
+		this.testClient = MockMvcTestClient.bindToController(controller).build();
+	}
+
+
+	@Test
+	public void stringWithCorrectResponseHeaderValue() {
+		testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, minuteAgo)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals(LAST_MODIFIED, now);
+	}
+
+	@Test
+	public void stringWithMatcherAndCorrectResponseHeaderValue() {
+		testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, minuteAgo)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().value(LAST_MODIFIED, equalTo(now));
+	}
+
+	@Test
+	public void multiStringHeaderValue() {
+		testClient.get().uri("/persons/1")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals(VARY, "foo", "bar");
+	}
+
+	@Test
+	public void multiStringHeaderValueWithMatchers() {
+		testClient.get().uri("/persons/1")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().values(VARY, hasItems(containsString("foo"), startsWith("bar")));
+	}
+
+	@Test
+	public void dateValueWithCorrectResponseHeaderValue() {
+		testClient.get().uri("/persons/1")
+				.header(IF_MODIFIED_SINCE, minuteAgo)
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEqualsDate(LAST_MODIFIED, this.currentTime);
+	}
+
+	@Test
+	public void longValueWithCorrectResponseHeaderValue() {
+		testClient.get().uri("/persons/1")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals("X-Rate-Limiting", 42);
+	}
+
+	@Test
+	public void stringWithMissingResponseHeader() {
+		testClient.get().uri("/persons/1")
+				.header(IF_MODIFIED_SINCE, now)
+				.exchange()
+				.expectStatus().isNotModified()
+				.expectHeader().valueEquals("X-Custom-Header");
+	}
+
+	@Test
+	public void stringWithMatcherAndMissingResponseHeader() {
+		testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, now)
+				.exchange()
+				.expectStatus().isNotModified()
+				.expectHeader().value("X-Custom-Header", nullValue());
+	}
+
+	@Test
+	public void longValueWithMissingResponseHeader() {
+		try {
+			testClient.get().uri("/persons/1").header(IF_MODIFIED_SINCE, now)
+					.exchange()
+					.expectStatus().isNotModified()
+					.expectHeader().valueEquals("X-Custom-Header", 99L);
+
+			fail(ERROR_MESSAGE);
+		}
+		catch (AssertionError err) {
+			if (ERROR_MESSAGE.equals(err.getMessage())) {
+				throw err;
+			}
+			assertThat(err.getMessage()).startsWith("Response does not contain header 'X-Custom-Header'");
+		}
+	}
+
+	@Test
+	public void exists() {
+		testClient.get().uri("/persons/1")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().exists(LAST_MODIFIED);
+	}
+
+	@Test
+	public void existsFail() {
+		assertThatExceptionOfType(AssertionError.class).isThrownBy(() ->
+				testClient.get().uri("/persons/1")
+						.exchange()
+						.expectStatus().isOk()
+						.expectHeader().exists("X-Custom-Header"));
+	}
+
+	@Test
+	public void doesNotExist() {
+		testClient.get().uri("/persons/1")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().doesNotExist("X-Custom-Header");
+	}
+
+	@Test
+	public void doesNotExistFail() {
+		assertThatExceptionOfType(AssertionError.class).isThrownBy(() ->
+				testClient.get().uri("/persons/1")
+						.exchange()
+						.expectStatus().isOk()
+						.expectHeader().doesNotExist(LAST_MODIFIED));
+	}
+
+	@Test
+	public void longValueWithIncorrectResponseHeaderValue() {
+		assertThatExceptionOfType(AssertionError.class).isThrownBy(() ->
+				testClient.get().uri("/persons/1")
+						.exchange()
+						.expectStatus().isOk()
+						.expectHeader().valueEquals("X-Rate-Limiting", 1));
+	}
+
+	@Test
+	public void stringWithMatcherAndIncorrectResponseHeaderValue() {
+		long secondLater = this.currentTime + 1000;
+		String expected = this.dateFormat.format(new Date(secondLater));
+		assertIncorrectResponseHeader(spec -> spec.expectHeader().valueEquals(LAST_MODIFIED, expected), expected);
+		assertIncorrectResponseHeader(spec -> spec.expectHeader().value(LAST_MODIFIED, equalTo(expected)), expected);
+		// Comparison by date uses HttpHeaders to format the date in the error message.
+		HttpHeaders headers = new HttpHeaders();
+		headers.setDate("expected", secondLater);
+		assertIncorrectResponseHeader(spec -> spec.expectHeader().valueEqualsDate(LAST_MODIFIED, secondLater), expected);
+	}
+
+	private void assertIncorrectResponseHeader(Consumer assertions, String expected) {
+		try {
+			WebTestClient.ResponseSpec spec = testClient.get().uri("/persons/1")
+					.header(IF_MODIFIED_SINCE, minuteAgo)
+					.exchange()
+					.expectStatus().isOk();
+
+			assertions.accept(spec);
+
+			fail(ERROR_MESSAGE);
+		}
+		catch (AssertionError err) {
+			if (ERROR_MESSAGE.equals(err.getMessage())) {
+				throw err;
+			}
+			assertMessageContains(err, "Response header '" + LAST_MODIFIED + "'");
+			assertMessageContains(err, expected);
+			assertMessageContains(err, this.now);
+		}
+	}
+
+	private void assertMessageContains(AssertionError error, String expected) {
+		assertThat(error.getMessage().contains(expected))
+				.as("Failure message should contain [" + expected + "], actual is [" + error.getMessage() + "]")
+				.isTrue();
+	}
+
+
+	@Controller
+	private static class PersonController {
+
+		private long timestamp;
+
+		public void setStubTimestamp(long timestamp) {
+			this.timestamp = timestamp;
+		}
+
+		@RequestMapping("/persons/{id}")
+		public ResponseEntity showEntity(@PathVariable long id, WebRequest request) {
+			return ResponseEntity
+					.ok()
+					.lastModified(this.timestamp)
+					.header("X-Rate-Limiting", "42")
+					.header("Vary", "foo", "bar")
+					.body(new Person("Jason"));
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java
new file mode 100644
index 00000000000..50216806c12
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/JsonPathAssertionTests.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.util.Arrays;
+
+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.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+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;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.JsonPathAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class JsonPathAssertionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new MusicController())
+					.alwaysExpect(status().isOk())
+					.alwaysExpect(content().contentType(MediaType.APPLICATION_JSON))
+					.configureClient()
+					.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+					.build();
+
+
+	@Test
+	public void exists() {
+		String composerByName = "$.composers[?(@.name == '%s')]";
+		String performerByName = "$.performers[?(@.name == '%s')]";
+
+		client.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.jsonPath(composerByName, "Johann Sebastian Bach").exists()
+				.jsonPath(composerByName, "Johannes Brahms").exists()
+				.jsonPath(composerByName, "Edvard Grieg").exists()
+				.jsonPath(composerByName, "Robert Schumann").exists()
+				.jsonPath(performerByName, "Vladimir Ashkenazy").exists()
+				.jsonPath(performerByName, "Yehudi Menuhin").exists()
+				.jsonPath("$.composers[0]").exists()
+				.jsonPath("$.composers[1]").exists()
+				.jsonPath("$.composers[2]").exists()
+				.jsonPath("$.composers[3]").exists();
+	}
+
+	@Test
+	public 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
+	public 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
+	public 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
+	public void hamcrestMatcherWithParameterizedJsonPath() {
+		String composerName = "$.composers[%s].name";
+		String performerName = "$.performers[%s].name";
+
+		client.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.jsonPath(composerName, 0).value(startsWith("Johann"))
+				.jsonPath(performerName, 0).value(endsWith("Ashkenazy"))
+				.jsonPath(performerName, 1).value(containsString("di Me"))
+				.jsonPath(composerName, 1).value(is(in(Arrays.asList("Johann Sebastian Bach", "Johannes Brahms"))));
+	}
+
+
+	@RestController
+	private class MusicController {
+
+		@RequestMapping("/music/people")
+		public MultiValueMap get() {
+			MultiValueMap map = new LinkedMultiValueMap<>();
+
+			map.add("composers", new Person("Johann Sebastian Bach"));
+			map.add("composers", new Person("Johannes Brahms"));
+			map.add("composers", new Person("Edvard Grieg"));
+			map.add("composers", new Person("Robert Schumann"));
+
+			map.add("performers", new Person("Vladimir Ashkenazy"));
+			map.add("performers", new Person("Yehudi Menuhin"));
+
+			return map;
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ModelAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ModelAssertionTests.java
new file mode 100644
index 00000000000..274000430c3
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ModelAssertionTests.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import javax.validation.Valid;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.ui.Model;
+import org.springframework.validation.BindingResult;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import static org.hamcrest.Matchers.allOf;
+import static org.hamcrest.Matchers.endsWith;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasProperty;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.startsWith;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.ModelAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class ModelAssertionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new SampleController("a string value", 3, new Person("a name")))
+					.controllerAdvice(new ModelAttributeAdvice())
+					.alwaysExpect(status().isOk())
+					.build();
+
+	@Test
+	void attributeEqualTo() throws Exception {
+		performRequest(HttpMethod.GET, "/")
+			.andExpect(model().attribute("integer", 3))
+			.andExpect(model().attribute("string", "a string value"))
+			.andExpect(model().attribute("integer", equalTo(3))) // Hamcrest...
+			.andExpect(model().attribute("string", equalTo("a string value")))
+			.andExpect(model().attribute("globalAttrName", equalTo("Global Attribute Value")));
+	}
+
+	@Test
+	void attributeExists() throws Exception {
+		performRequest(HttpMethod.GET, "/")
+			.andExpect(model().attributeExists("integer", "string", "person"))
+			.andExpect(model().attribute("integer", notNullValue()))  // Hamcrest...
+			.andExpect(model().attribute("INTEGER", nullValue()));
+	}
+
+	@Test
+	void attributeHamcrestMatchers() throws Exception {
+		performRequest(HttpMethod.GET, "/")
+			.andExpect(model().attribute("integer", equalTo(3)))
+			.andExpect(model().attribute("string", allOf(startsWith("a string"), endsWith("value"))))
+			.andExpect(model().attribute("person", hasProperty("name", equalTo("a name"))));
+	}
+
+	@Test
+	void hasErrors() throws Exception {
+		performRequest(HttpMethod.POST, "/persons").andExpect(model().attributeHasErrors("person"));
+	}
+
+	@Test
+	void hasNoErrors() throws Exception {
+		performRequest(HttpMethod.GET, "/").andExpect(model().hasNoErrors());
+	}
+
+	private ResultActions performRequest(HttpMethod method, String uri) {
+		EntityExchangeResult result = client.method(method).uri(uri).exchange().expectBody().isEmpty();
+		return MockMvcTestClient.resultActionsFor(result);
+	}
+
+
+	@Controller
+	private static class SampleController {
+
+		private final Object[] values;
+
+		SampleController(Object... values) {
+			this.values = values;
+		}
+
+		@RequestMapping("/")
+		String handle(Model model) {
+			for (Object value : this.values) {
+				model.addAttribute(value);
+			}
+			return "view";
+		}
+
+		@PostMapping("/persons")
+		String create(@Valid Person person, BindingResult result, Model model) {
+			return "view";
+		}
+	}
+
+	@ControllerAdvice
+	private static class ModelAttributeAdvice {
+
+		@ModelAttribute("globalAttrName")
+		String getAttribute() {
+			return "Global Attribute Value";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/RequestAttributeAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/RequestAttributeAssertionTests.java
new file mode 100644
index 00000000000..bc48bccdf7e
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/RequestAttributeAssertionTests.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.servlet.HandlerMapping;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.RequestAttributeAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class RequestAttributeAssertionTests {
+
+	private final WebTestClient mainServletClient =
+			MockMvcTestClient.bindToController(new SimpleController())
+					.defaultRequest(get("/").servletPath("/main"))
+					.build();
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new SimpleController()).build();
+
+
+	@Test
+	void requestAttributeEqualTo() throws Exception {
+		performRequest(mainServletClient, "/main/1")
+			.andExpect(request().attribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/{id}"))
+			.andExpect(request().attribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/1"));
+	}
+
+	@Test
+	void requestAttributeMatcher() throws Exception {
+		String producibleMediaTypes = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE;
+
+		performRequest(client, "/1")
+			.andExpect(request().attribute(producibleMediaTypes, hasItem(MediaType.APPLICATION_JSON)))
+			.andExpect(request().attribute(producibleMediaTypes, not(hasItem(MediaType.APPLICATION_XML))));
+
+		performRequest(mainServletClient, "/main/1")
+			.andExpect(request().attribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, equalTo("/{id}")))
+			.andExpect(request().attribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, equalTo("/1")));
+	}
+
+	private ResultActions performRequest(WebTestClient client, String uri) {
+		EntityExchangeResult result = client.get().uri(uri)
+				.exchange()
+				.expectStatus().isOk()
+				.expectBody().isEmpty();
+
+		return MockMvcTestClient.resultActionsFor(result);
+	}
+
+
+	@Controller
+	private static class SimpleController {
+
+		@GetMapping(path="/{id}", produces="application/json")
+		String show() {
+			return "view";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/SessionAttributeAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/SessionAttributeAssertionTests.java
new file mode 100644
index 00000000000..5b1add31ffe
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/SessionAttributeAssertionTests.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.SessionAttributes;
+
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.hamcrest.Matchers.nullValue;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.SessionAttributeAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class SessionAttributeAssertionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new SimpleController())
+					.alwaysExpect(status().isOk())
+					.build();
+
+
+	@Test
+	void sessionAttributeEqualTo() throws Exception {
+		performRequest().andExpect(request().sessionAttribute("locale", Locale.UK));
+
+		assertThatExceptionOfType(AssertionError.class)
+			.isThrownBy(() -> performRequest().andExpect(request().sessionAttribute("locale", Locale.US)))
+			.withMessage("Session attribute 'locale' expected: but was:");
+	}
+
+	@Test
+	void sessionAttributeMatcher() throws Exception {
+		performRequest()
+			.andExpect(request().sessionAttribute("bogus", is(nullValue())))
+			.andExpect(request().sessionAttribute("locale", is(notNullValue())))
+			.andExpect(request().sessionAttribute("locale", equalTo(Locale.UK)));
+
+		assertThatExceptionOfType(AssertionError.class)
+			.isThrownBy(() -> performRequest().andExpect(request().sessionAttribute("bogus", is(notNullValue()))))
+			.withMessageContaining("null");
+	}
+
+	@Test
+	void sessionAttributeDoesNotExist() throws Exception {
+		performRequest().andExpect(request().sessionAttributeDoesNotExist("bogus", "enigma"));
+
+		assertThatExceptionOfType(AssertionError.class)
+			.isThrownBy(() -> performRequest().andExpect(request().sessionAttributeDoesNotExist("locale")))
+			.withMessage("Session attribute 'locale' exists");
+	}
+
+	private ResultActions performRequest() {
+		EntityExchangeResult result = client.post().uri("/").exchange().expectBody().isEmpty();
+		return MockMvcTestClient.resultActionsFor(result);
+	}
+
+
+	@Controller
+	@SessionAttributes("locale")
+	private static class SimpleController {
+
+		@ModelAttribute
+		void populate(Model model) {
+			model.addAttribute("locale", Locale.UK);
+		}
+
+		@RequestMapping("/")
+		String handle() {
+			return "view";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java
new file mode 100644
index 00000000000..80759c5fcb3
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/StatusAssertionTests.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.annotation.AliasFor;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+import static org.hamcrest.Matchers.equalTo;
+import static org.springframework.http.HttpStatus.BAD_REQUEST;
+import static org.springframework.http.HttpStatus.CREATED;
+import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
+import static org.springframework.http.HttpStatus.NOT_IMPLEMENTED;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.StatusAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class StatusAssertionTests {
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new StatusController()).build();
+
+
+	@Test
+	public void testStatusInt() {
+		testClient.get().uri("/created").exchange().expectStatus().isEqualTo(201);
+		testClient.get().uri("/createdWithComposedAnnotation").exchange().expectStatus().isEqualTo(201);
+		testClient.get().uri("/badRequest").exchange().expectStatus().isEqualTo(400);
+	}
+
+	@Test
+	public void testHttpStatus() {
+		testClient.get().uri("/created").exchange().expectStatus().isCreated();
+		testClient.get().uri("/createdWithComposedAnnotation").exchange().expectStatus().isCreated();
+		testClient.get().uri("/badRequest").exchange().expectStatus().isBadRequest();
+	}
+
+	@Test
+	public void testMatcher() {
+		testClient.get().uri("/badRequest").exchange().expectStatus().value(equalTo(400));
+	}
+
+
+	@RequestMapping
+	@ResponseStatus
+	@Retention(RetentionPolicy.RUNTIME)
+	@interface Get {
+
+		@AliasFor(annotation = RequestMapping.class, attribute = "path")
+		String[] path() default {};
+
+		@AliasFor(annotation = ResponseStatus.class, attribute = "code")
+		HttpStatus status() default INTERNAL_SERVER_ERROR;
+	}
+
+	@Controller
+	private static class StatusController {
+
+		@RequestMapping("/created")
+		@ResponseStatus(CREATED)
+		public @ResponseBody void created(){
+		}
+
+		@Get(path = "/createdWithComposedAnnotation", status = CREATED)
+		public @ResponseBody void createdWithComposedAnnotation() {
+		}
+
+		@RequestMapping("/badRequest")
+		@ResponseStatus(code = BAD_REQUEST, reason = "Expired token")
+		public @ResponseBody void badRequest(){
+		}
+
+		@RequestMapping("/notImplemented")
+		@ResponseStatus(NOT_IMPLEMENTED)
+		public @ResponseBody void notImplemented(){
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/UrlAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/UrlAssertionTests.java
new file mode 100644
index 00000000000..15f8bb19bff
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/UrlAssertionTests.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrlPattern;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.UrlAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class UrlAssertionTests {
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new SimpleController()).build();
+
+
+	@Test
+	public void testRedirect() {
+		testClient.get().uri("/persons")
+				.exchange()
+				.expectStatus().isFound()
+				.expectHeader().location("/persons/1");
+	}
+
+	@Test
+	public void testRedirectPattern() throws Exception {
+		EntityExchangeResult result =
+				testClient.get().uri("/persons").exchange().expectBody().isEmpty();
+
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(redirectedUrlPattern("/persons/*"));
+	}
+
+	@Test
+	public void testForward() {
+		testClient.get().uri("/")
+				.exchange()
+				.expectStatus().isOk()
+				.expectHeader().valueEquals("Forwarded-Url", "/home");
+	}
+
+	@Test
+	public void testForwardPattern() throws Exception {
+		EntityExchangeResult result =
+				testClient.get().uri("/").exchange().expectBody().isEmpty();
+
+		MockMvcTestClient.resultActionsFor(result)
+				.andExpect(forwardedUrlPattern("/ho?e"));
+	}
+
+
+	@Controller
+	private static class SimpleController {
+
+		@RequestMapping("/persons")
+		public String save() {
+			return "redirect:/persons/1";
+		}
+
+		@RequestMapping("/")
+		public String forward() {
+			return "forward:/home";
+		}
+	}
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ViewNameAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ViewNameAssertionTests.java
new file mode 100644
index 00000000000..b60196e01dd
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/ViewNameAssertionTests.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.reactive.server.EntityExchangeResult;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.UrlAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class ViewNameAssertionTests {
+
+	private final WebTestClient client =
+			MockMvcTestClient.bindToController(new SimpleController())
+					.alwaysExpect(status().isOk())
+					.build();
+
+
+	@Test
+	public void testEqualTo() throws Exception {
+		MockMvcTestClient.resultActionsFor(performRequest())
+			.andExpect(view().name("mySpecialView"))
+			.andExpect(view().name(equalTo("mySpecialView")));
+	}
+
+	@Test
+	public void testHamcrestMatcher() throws Exception {
+		MockMvcTestClient.resultActionsFor(performRequest())
+				.andExpect(view().name(containsString("Special")));
+	}
+
+	private EntityExchangeResult performRequest() {
+		return client.get().uri("/").exchange().expectBody().isEmpty();
+	}
+
+
+	@Controller
+	private static class SimpleController {
+
+		@RequestMapping("/")
+		public String handle() {
+			return "mySpecialView";
+		}
+	}
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/XmlContentAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/XmlContentAssertionTests.java
new file mode 100644
index 00000000000..26660211246
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/XmlContentAssertionTests.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.util.Arrays;
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlElementWrapper;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.XmlContentAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class XmlContentAssertionTests {
+
+	private static final String PEOPLE_XML =
+			"" +
+					"" +
+					"Johann Sebastian Bachfalse21.0" +
+					"Johannes Brahmsfalse0.0025" +
+					"Edvard Griegfalse1.6035" +
+					"Robert SchumannfalseNaN" +
+					"";
+
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new MusicController())
+					.alwaysExpect(status().isOk())
+					.alwaysExpect(content().contentType(MediaType.parseMediaType("application/xml;charset=UTF-8")))
+					.configureClient()
+					.defaultHeader(HttpHeaders.ACCEPT, "application/xml;charset=UTF-8")
+					.build();
+
+
+	@Test
+	public void testXmlEqualTo() {
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody().xml(PEOPLE_XML);
+	}
+
+	@Test
+	public void testNodeHamcrestMatcher() {
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody().xpath("/people/composers/composer[1]").exists();
+	}
+
+
+	@Controller
+	private static class MusicController {
+
+		@RequestMapping(value="/music/people")
+		public @ResponseBody PeopleWrapper getPeople() {
+
+			List composers = Arrays.asList(
+					new Person("Johann Sebastian Bach").setSomeDouble(21),
+					new Person("Johannes Brahms").setSomeDouble(.0025),
+					new Person("Edvard Grieg").setSomeDouble(1.6035),
+					new Person("Robert Schumann").setSomeDouble(Double.NaN));
+
+			return new PeopleWrapper(composers);
+		}
+	}
+
+	@SuppressWarnings("unused")
+	@XmlRootElement(name="people")
+	@XmlAccessorType(XmlAccessType.FIELD)
+	private static class PeopleWrapper {
+
+		@XmlElementWrapper(name="composers")
+		@XmlElement(name="composer")
+		private List composers;
+
+		public PeopleWrapper() {
+		}
+
+		public PeopleWrapper(List composers) {
+			this.composers = composers;
+		}
+
+		public List getComposers() {
+			return this.composers;
+		}
+	}
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/XpathAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/XpathAssertionTests.java
new file mode 100644
index 00000000000..c7fd97f6a10
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/resultmatches/XpathAssertionTests.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2002-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.test.web.servlet.samples.client.standalone.resultmatches;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlElementWrapper;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.test.web.servlet.client.MockMvcTestClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import static org.hamcrest.Matchers.closeTo;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.notNullValue;
+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;
+import static org.springframework.web.bind.annotation.RequestMethod.GET;
+import static org.springframework.web.bind.annotation.RequestMethod.HEAD;
+
+/**
+ * MockMvcTestClient equivalent of the MockMvc
+ * {@link org.springframework.test.web.servlet.samples.standalone.resultmatchers.XpathAssertionTests}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class XpathAssertionTests {
+
+	private static final Map musicNamespace =
+			Collections.singletonMap("ns", "https://example.org/music/people");
+
+	private final WebTestClient testClient =
+			MockMvcTestClient.bindToController(new MusicController())
+					.alwaysExpect(status().isOk())
+					.alwaysExpect(content().contentType(MediaType.parseMediaType("application/xml;charset=UTF-8")))
+					.configureClient()
+					.defaultHeader(HttpHeaders.ACCEPT, "application/xml;charset=UTF-8")
+					.build();
+
+
+	@Test
+	public void testExists() {
+		String composer = "/ns:people/composers/composer[%s]";
+		String performer = "/ns:people/performers/performer[%s]";
+
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.xpath(composer, musicNamespace, 1).exists()
+				.xpath(composer, musicNamespace, 2).exists()
+				.xpath(composer, musicNamespace, 3).exists()
+				.xpath(composer, musicNamespace, 4).exists()
+				.xpath(performer, musicNamespace, 1).exists()
+				.xpath(performer, musicNamespace, 2).exists()
+				.xpath(composer, musicNamespace, 1).string(notNullValue());
+	}
+
+	@Test
+	public void testDoesNotExist() {
+		String composer = "/ns:people/composers/composer[%s]";
+		String performer = "/ns:people/performers/performer[%s]";
+
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.xpath(composer, musicNamespace, 0).doesNotExist()
+				.xpath(composer, musicNamespace, 5).doesNotExist()
+				.xpath(performer, musicNamespace, 0).doesNotExist()
+				.xpath(performer, musicNamespace, 3).doesNotExist();
+	}
+
+	@Test
+	public void testString() {
+
+		String composerName = "/ns:people/composers/composer[%s]/name";
+		String performerName = "/ns:people/performers/performer[%s]/name";
+
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.xpath(composerName, musicNamespace, 1).isEqualTo("Johann Sebastian Bach")
+				.xpath(composerName, musicNamespace, 2).isEqualTo("Johannes Brahms")
+				.xpath(composerName, musicNamespace, 3).isEqualTo("Edvard Grieg")
+				.xpath(composerName, musicNamespace, 4).isEqualTo("Robert Schumann")
+				.xpath(performerName, musicNamespace, 1).isEqualTo("Vladimir Ashkenazy")
+				.xpath(performerName, musicNamespace, 2).isEqualTo("Yehudi Menuhin")
+				.xpath(composerName, musicNamespace, 1).string(equalTo("Johann Sebastian Bach")) // Hamcrest..
+				.xpath(composerName, musicNamespace, 1).string(startsWith("Johann"))
+				.xpath(composerName, musicNamespace, 1).string(notNullValue());
+	}
+
+	@Test
+	public void testNumber() {
+		String expression = "/ns:people/composers/composer[%s]/someDouble";
+
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.xpath(expression, musicNamespace, 1).isEqualTo(21d)
+				.xpath(expression, musicNamespace, 2).isEqualTo(.0025)
+				.xpath(expression, musicNamespace, 3).isEqualTo(1.6035)
+				.xpath(expression, musicNamespace, 4).isEqualTo(Double.NaN)
+				.xpath(expression, musicNamespace, 1).number(equalTo(21d))  // Hamcrest..
+				.xpath(expression, musicNamespace, 3).number(closeTo(1.6, .01));
+	}
+
+	@Test
+	public void testBoolean() {
+		String expression = "/ns:people/performers/performer[%s]/someBoolean";
+
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.xpath(expression, musicNamespace, 1).isEqualTo(false)
+				.xpath(expression, musicNamespace, 2).isEqualTo(true);
+	}
+
+	@Test
+	public void testNodeCount() {
+		testClient.get().uri("/music/people")
+				.exchange()
+				.expectBody()
+				.xpath("/ns:people/composers/composer", musicNamespace).nodeCount(4)
+				.xpath("/ns:people/performers/performer", musicNamespace).nodeCount(2)
+				.xpath("/ns:people/composers/composer", musicNamespace).nodeCount(equalTo(4)) // Hamcrest..
+				.xpath("/ns:people/performers/performer", musicNamespace).nodeCount(equalTo(2));
+	}
+
+	@Test
+	public void testFeedWithLinefeedChars() {
+		MockMvcTestClient.bindToController(new BlogFeedController()).build()
+				.get().uri("/blog.atom")
+				.accept(MediaType.APPLICATION_ATOM_XML)
+				.exchange()
+				.expectBody()
+				.xpath("//feed/title").isEqualTo("Test Feed")
+				.xpath("//feed/icon").isEqualTo("https://www.example.com/favicon.ico");
+	}
+
+
+	@Controller
+	private static class MusicController {
+
+		@RequestMapping(value = "/music/people")
+		public @ResponseBody
+		PeopleWrapper getPeople() {
+
+			List composers = Arrays.asList(
+					new Person("Johann Sebastian Bach").setSomeDouble(21),
+					new Person("Johannes Brahms").setSomeDouble(.0025),
+					new Person("Edvard Grieg").setSomeDouble(1.6035),
+					new Person("Robert Schumann").setSomeDouble(Double.NaN));
+
+			List performers = Arrays.asList(
+					new Person("Vladimir Ashkenazy").setSomeBoolean(false),
+					new Person("Yehudi Menuhin").setSomeBoolean(true));
+
+			return new PeopleWrapper(composers, performers);
+		}
+	}
+
+	@SuppressWarnings("unused")
+	@XmlRootElement(name = "people", namespace = "https://example.org/music/people")
+	@XmlAccessorType(XmlAccessType.FIELD)
+	private static class PeopleWrapper {
+
+		@XmlElementWrapper(name = "composers")
+		@XmlElement(name = "composer")
+		private List composers;
+
+		@XmlElementWrapper(name = "performers")
+		@XmlElement(name = "performer")
+		private List performers;
+
+		public PeopleWrapper() {
+		}
+
+		public PeopleWrapper(List composers, List performers) {
+			this.composers = composers;
+			this.performers = performers;
+		}
+
+		public List getComposers() {
+			return this.composers;
+		}
+
+		public List getPerformers() {
+			return this.performers;
+		}
+	}
+
+
+	@Controller
+	public class BlogFeedController {
+
+		@RequestMapping(value = "/blog.atom", method = {GET, HEAD})
+		@ResponseBody
+		public String listPublishedPosts() {
+			return "\r\n"
+					+ "\r\n"
+					+ "  Test Feed\r\n"
+					+ "  https://www.example.com/favicon.ico\r\n"
+					+ "\r\n\r\n";
+		}
+	}
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/PersonController.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/PersonController.java
index fa3ad7fa263..31781062ba6 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/PersonController.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/PersonController.java
@@ -29,7 +29,7 @@ public class PersonController {
 	private final PersonDao personDao;
 
 
-	PersonController(PersonDao personDao) {
+	public PersonController(PersonDao personDao) {
 		this.personDao = personDao;
 	}
 
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java
index 65d8be3be96..1f343fb17b7 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/AsyncTests.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2020 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.
@@ -18,12 +18,12 @@ package org.springframework.test.web.servlet.samples.standalone;
 
 import java.io.StringWriter;
 import java.nio.charset.StandardCharsets;
-import java.util.Collection;
+import java.time.Duration;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CopyOnWriteArrayList;
 
 import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
 
 import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
@@ -113,8 +113,6 @@ public class AsyncTests {
 				.andExpect(request().asyncStarted())
 				.andReturn();
 
-		this.asyncController.onMessage("Joe");
-
 		this.mockMvc.perform(asyncDispatch(mvcResult))
 				.andExpect(status().isOk())
 				.andExpect(content().contentType(MediaType.APPLICATION_JSON))
@@ -151,8 +149,6 @@ public class AsyncTests {
 				.andExpect(request().asyncStarted())
 				.andReturn();
 
-		this.asyncController.onMessage("Joe");
-
 		this.mockMvc.perform(asyncDispatch(mvcResult))
 				.andExpect(status().isOk())
 				.andExpect(content().contentType(MediaType.APPLICATION_JSON))
@@ -183,8 +179,6 @@ public class AsyncTests {
 		assertThat(writer.toString().contains("Async started = true")).isTrue();
 		writer = new StringWriter();
 
-		this.asyncController.onMessage("Joe");
-
 		this.mockMvc.perform(asyncDispatch(mvcResult))
 				.andDo(print(writer))
 				.andExpect(status().isOk())
@@ -199,10 +193,6 @@ public class AsyncTests {
 	@RequestMapping(path = "/{id}", produces = "application/json")
 	private static class AsyncController {
 
-		private final Collection> deferredResults = new CopyOnWriteArrayList<>();
-
-		private final Collection> futureTasks = new CopyOnWriteArrayList<>();
-
 		@RequestMapping(params = "callable")
 		public Callable getCallable() {
 			return () -> new Person("Joe");
@@ -235,9 +225,9 @@ public class AsyncTests {
 
 		@RequestMapping(params = "deferredResult")
 		public DeferredResult getDeferredResult() {
-			DeferredResult deferredResult = new DeferredResult<>();
-			this.deferredResults.add(deferredResult);
-			return deferredResult;
+			DeferredResult result = new DeferredResult<>();
+			delay(100, () -> result.setResult(new Person("Joe")));
+			return result;
 		}
 
 		@RequestMapping(params = "deferredResultWithImmediateValue")
@@ -249,26 +239,15 @@ public class AsyncTests {
 
 		@RequestMapping(params = "deferredResultWithDelayedError")
 		public DeferredResult getDeferredResultWithDelayedError() {
-			final DeferredResult deferredResult = new DeferredResult<>();
-			new Thread() {
-				@Override
-				public void run() {
-					try {
-						Thread.sleep(100);
-						deferredResult.setErrorResult(new RuntimeException("Delayed Error"));
-					}
-					catch (InterruptedException e) {
-						/* no-op */
-					}
-				}
-			}.start();
-			return deferredResult;
+			DeferredResult result = new DeferredResult<>();
+			delay(100, () -> result.setErrorResult(new RuntimeException("Delayed Error")));
+			return result;
 		}
 
 		@RequestMapping(params = "listenableFuture")
 		public ListenableFuture getListenableFuture() {
 			ListenableFutureTask futureTask = new ListenableFutureTask<>(() -> new Person("Joe"));
-			this.futureTasks.add(futureTask);
+			delay(100, futureTask);
 			return futureTask;
 		}
 
@@ -281,19 +260,12 @@ public class AsyncTests {
 
 		@ExceptionHandler(Exception.class)
 		@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
-		public String errorHandler(Exception e) {
-			return e.getMessage();
+		public String errorHandler(Exception ex) {
+			return ex.getMessage();
 		}
 
-		void onMessage(String name) {
-			for (DeferredResult deferredResult : this.deferredResults) {
-				deferredResult.setResult(new Person(name));
-				this.deferredResults.remove(deferredResult);
-			}
-			for (ListenableFutureTask futureTask : this.futureTasks) {
-				futureTask.run();
-				this.futureTasks.remove(futureTask);
-			}
+		private void delay(long millis, Runnable task) {
+			Mono.delay(Duration.ofMillis(millis)).doOnTerminate(task).subscribe();
 		}
 	}
 
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ExceptionHandlerTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ExceptionHandlerTests.java
index 1e21a37c708..d6b026bed5c 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ExceptionHandlerTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ExceptionHandlerTests.java
@@ -42,7 +42,7 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
  * @author Rossen Stoyanchev
  * @author Sam Brannen
  */
-class ExceptionHandlerTests {
+public class ExceptionHandlerTests {
 
 	@Nested
 	class MvcTests {
@@ -62,14 +62,6 @@ class ExceptionHandlerTests {
 				.andExpect(status().isOk())
 				.andExpect(forwardedUrl("globalErrorView"));
 		}
-
-		@Test
-		void globalExceptionHandlerMethodUsingClassArgument() throws Exception {
-			standaloneSetup(PersonController.class).setControllerAdvice(GlobalExceptionHandler.class).build()
-				.perform(get("/person/Bonnie"))
-				.andExpect(status().isOk())
-				.andExpect(forwardedUrl("globalErrorView"));
-		}
 	}
 
 
@@ -146,7 +138,7 @@ class ExceptionHandlerTests {
 		void noHandlerFound() throws Exception {
 			standaloneSetup(RestPersonController.class)
 				.setControllerAdvice(RestGlobalExceptionHandler.class, RestPersonControllerExceptionHandler.class)
-				.addDispatcherServletCustomizer(dispatcherServlet -> dispatcherServlet.setThrowExceptionIfNoHandlerFound(true))
+				.addDispatcherServletCustomizer(servlet -> servlet.setThrowExceptionIfNoHandlerFound(true))
 				.build()
 				.perform(get("/bogus").accept(MediaType.APPLICATION_JSON))
 				.andExpect(status().isOk())
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java
index 72daced4b9d..7d23bebc0b5 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resulthandlers/PrintingResultHandlerSmokeTests.java
@@ -48,9 +48,12 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
  * @author Sam Brannen
  * @see org.springframework.test.web.servlet.result.PrintingResultHandlerTests
  */
-@Disabled("Not intended to be executed with the build. Comment out this line to inspect the output manually.")
+@Disabled
 public class PrintingResultHandlerSmokeTests {
 
+	// Not intended to be executed with the build.
+	// Comment out class-level @Disabled to see the output.
+
 	@Test
 	public void testPrint() throws Exception {
 		StringWriter writer = new StringWriter();
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/FlashAttributeAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/FlashAttributeAssertionTests.java
index 89466b75dfd..a6a5b7fc086 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/FlashAttributeAssertionTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/FlashAttributeAssertionTests.java
@@ -41,7 +41,7 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
  * @author Rossen Stoyanchev
  * @author Sam Brannen
  */
-class FlashAttributeAssertionTests {
+public class FlashAttributeAssertionTests {
 
 	private final MockMvc mockMvc = standaloneSetup(new PersonController())
 										.alwaysExpect(status().isFound())
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/ModelAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/ModelAssertionTests.java
index 6856253c3ab..11473a31ab1 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/ModelAssertionTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/ModelAssertionTests.java
@@ -49,7 +49,7 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
  *
  * @author Rossen Stoyanchev
  */
-class ModelAssertionTests {
+public class ModelAssertionTests {
 
 	private MockMvc mockMvc;
 
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/RequestAttributeAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/RequestAttributeAssertionTests.java
index 37efd72414f..7f98ce33007 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/RequestAttributeAssertionTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/RequestAttributeAssertionTests.java
@@ -36,7 +36,7 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
  *
  * @author Rossen Stoyanchev
  */
-class RequestAttributeAssertionTests {
+public class RequestAttributeAssertionTests {
 
 	private final MockMvc mockMvc = standaloneSetup(new SimpleController()).build();
 
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/SessionAttributeAssertionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/SessionAttributeAssertionTests.java
index 508c9f63720..d60a4c1428b 100644
--- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/SessionAttributeAssertionTests.java
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/resultmatchers/SessionAttributeAssertionTests.java
@@ -43,7 +43,7 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standal
  * @author Rossen Stoyanchev
  * @author Sam Brannen
  */
-class SessionAttributeAssertionTests {
+public class SessionAttributeAssertionTests {
 
 	private final MockMvc mockMvc = standaloneSetup(new SimpleController())
 										.defaultRequest(get("/"))