diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java
new file mode 100644
index 0000000000..401aab9989
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.StreamSupport;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.lang.Nullable;
+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.MockMvcRequestBuilders;
+import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder;
+import org.springframework.util.Assert;
+import org.springframework.web.context.WebApplicationContext;
+
+/**
+ * {@link MockMvc} variant that tests Spring MVC exchanges and provide fluent
+ * assertions using {@link org.assertj.core.api.Assertions AssertJ}.
+ *
+ *
A main difference with {@link MockMvc} is that an unresolved exception
+ * is not thrown directly. Rather an {@link AssertableMvcResult} is available
+ * with an {@link AssertableMvcResult#getUnresolvedException() unresolved
+ * exception}.
+ *
+ *
{@link AssertableMockMvc} can be configured with a list of
+ * {@linkplain HttpMessageConverter HttpMessageConverters} to allow response
+ * body to be deserialized, rather than asserting on the raw values.
+ *
+ * @author Stephane Nicoll
+ * @author Brian Clozel
+ * @since 6.2
+ */
+public final class AssertableMockMvc {
+
+ private static final MediaType JSON = MediaType.APPLICATION_JSON;
+
+ private final MockMvc mockMvc;
+
+ @Nullable
+ private final GenericHttpMessageConverter jsonMessageConverter;
+
+
+ private AssertableMockMvc(MockMvc mockMvc, @Nullable GenericHttpMessageConverter jsonMessageConverter) {
+ Assert.notNull(mockMvc, "mockMVC should not be null");
+ this.mockMvc = mockMvc;
+ this.jsonMessageConverter = jsonMessageConverter;
+ }
+
+ /**
+ * Create a new {@link AssertableMockMvc} instance that delegates to the
+ * given {@link MockMvc}.
+ * @param mockMvc the MockMvc instance to delegate calls to
+ */
+ public static AssertableMockMvc create(MockMvc mockMvc) {
+ return new AssertableMockMvc(mockMvc, null);
+ }
+
+ /**
+ * Create a {@link AssertableMockMvc} instance using the given, fully
+ * initialized (i.e., refreshed ) {@link WebApplicationContext}. The
+ * given {@code customizations} are applied to the {@link DefaultMockMvcBuilder}
+ * that ultimately creates the underlying {@link MockMvc} instance.
+ * If no further customization of the underlying {@link MockMvc} instance
+ * is required, use {@link #from(WebApplicationContext)}.
+ * @param applicationContext the application context to detect the Spring
+ * MVC infrastructure and application controllers from
+ * @param customizations the function that creates a {@link MockMvc}
+ * instance based on a {@link DefaultMockMvcBuilder}.
+ * @see MockMvcBuilders#webAppContextSetup(WebApplicationContext)
+ */
+ public static AssertableMockMvc from(WebApplicationContext applicationContext,
+ Function customizations) {
+
+ DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(applicationContext);
+ MockMvc mockMvc = customizations.apply(builder);
+ return create(mockMvc);
+ }
+
+ /**
+ * Shortcut to create a {@link AssertableMockMvc} instance using the given,
+ * fully initialized (i.e., refreshed ) {@link WebApplicationContext}.
+ * Consider using {@link #from(WebApplicationContext, Function)} if
+ * further customizations of the underlying {@link MockMvc} instance is
+ * required.
+ * @param applicationContext the application context to detect the Spring
+ * MVC infrastructure and application controllers from
+ * @see MockMvcBuilders#webAppContextSetup(WebApplicationContext)
+ */
+ public static AssertableMockMvc from(WebApplicationContext applicationContext) {
+ return from(applicationContext, DefaultMockMvcBuilder::build);
+ }
+
+ /**
+ * Create a {@link AssertableMockMvc} instance by registering one or more
+ * {@code @Controller} instances and configuring Spring MVC infrastructure
+ * programmatically.
+ *
This allows full control over the instantiation and initialization of
+ * controllers and their dependencies, similar to plain unit tests while
+ * also making it possible to test one controller at a time.
+ * @param controllers one or more {@code @Controller} instances to test
+ * (specified {@code Class} will be turned into instance)
+ * @param customizations the function that creates a {@link MockMvc}
+ * instance based on a {@link StandaloneMockMvcBuilder}, typically to
+ * configure the Spring MVC infrastructure
+ * @see MockMvcBuilders#standaloneSetup(Object...)
+ */
+ public static AssertableMockMvc of(Collection> controllers,
+ Function customizations) {
+
+ StandaloneMockMvcBuilder builder = MockMvcBuilders.standaloneSetup(controllers.toArray());
+ return create(customizations.apply(builder));
+ }
+
+ /**
+ * Shortcut to create a {@link AssertableMockMvc} instance by registering
+ * one or more {@code @Controller} instances.
+ * The minimum infrastructure required by the
+ * {@link org.springframework.web.servlet.DispatcherServlet DispatcherServlet}
+ * to serve requests with annotated controllers is created. Consider using
+ * {@link #of(Collection, Function)} if additional configuration of the MVC
+ * infrastructure is required.
+ * @param controllers one or more {@code @Controller} instances to test
+ * (specified {@code Class} will be turned into instance)
+ * @see MockMvcBuilders#standaloneSetup(Object...)
+ */
+ public static AssertableMockMvc of(Object... controllers) {
+ return of(Arrays.asList(controllers), StandaloneMockMvcBuilder::build);
+ }
+
+ /**
+ * Return a new {@link AssertableMockMvc} instance using the specified
+ * {@link HttpMessageConverter}. If none are specified, only basic assertions
+ * on the response body can be performed. Consider registering a suitable
+ * JSON converter for asserting data structure.
+ * @param httpMessageConverters the message converters to use
+ * @return a new instance using the specified converters
+ */
+ public AssertableMockMvc withHttpMessageConverters(Iterable> httpMessageConverters) {
+ return new AssertableMockMvc(this.mockMvc, findJsonMessageConverter(httpMessageConverters));
+ }
+
+ /**
+ * Perform a request and return a type that can be used with standard
+ * {@link org.assertj.core.api.Assertions AssertJ} assertions.
+ * Use static methods of {@link MockMvcRequestBuilders} to prepare the
+ * request, wrapping the invocation in {@code assertThat}. The following
+ * asserts that a {@linkplain MockMvcRequestBuilders#get(URI) GET} request
+ * against "/greet" has an HTTP status code 200 (OK), and a simple body:
+ *
assertThat(mvc.perform(get("/greet")))
+ * .hasStatusOk()
+ * .body().asString().isEqualTo("Hello");
+ *
+ * Contrary to {@link MockMvc#perform(RequestBuilder)}, this does not
+ * throw an exception if the request fails with an unresolved exception.
+ * Rather, the result provides the exception, if any. Assuming that a
+ * {@linkplain MockMvcRequestBuilders#post(URI) POST} request against
+ * {@code /boom} throws an {@code IllegalStateException}, the following
+ * asserts that the invocation has indeed failed with the expected error
+ * message:
+ *
assertThat(mvc.perform(post("/boom")))
+ * .unresolvedException().isInstanceOf(IllegalStateException.class)
+ * .hasMessage("Expected");
+ *
+ *
+ * @param requestBuilder used to prepare the request to execute;
+ * see static factory methods in
+ * {@link org.springframework.test.web.servlet.request.MockMvcRequestBuilders}
+ * @return an {@link AssertableMvcResult} to be wrapped in {@code assertThat}
+ * @see MockMvc#perform(RequestBuilder)
+ */
+ public AssertableMvcResult perform(RequestBuilder requestBuilder) {
+ Object result = getMvcResultOrFailure(requestBuilder);
+ if (result instanceof MvcResult mvcResult) {
+ return new DefaultAssertableMvcResult(mvcResult, null, this.jsonMessageConverter);
+ }
+ else {
+ return new DefaultAssertableMvcResult(null, (Exception) result, this.jsonMessageConverter);
+ }
+ }
+
+ private Object getMvcResultOrFailure(RequestBuilder requestBuilder) {
+ try {
+ return this.mockMvc.perform(requestBuilder).andReturn();
+ }
+ catch (Exception ex) {
+ return ex;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ private GenericHttpMessageConverter findJsonMessageConverter(
+ Iterable> messageConverters) {
+
+ return StreamSupport.stream(messageConverters.spliterator(), false)
+ .filter(GenericHttpMessageConverter.class::isInstance)
+ .map(GenericHttpMessageConverter.class::cast)
+ .filter(converter -> converter.canWrite(null, Map.class, JSON))
+ .filter(converter -> converter.canRead(Map.class, JSON))
+ .findFirst().orElse(null);
+ }
+
+}
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java
new file mode 100644
index 0000000000..c160da7e81
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import org.assertj.core.api.AssertProvider;
+
+import org.springframework.lang.Nullable;
+import org.springframework.test.web.servlet.MvcResult;
+
+/**
+ * A {@link MvcResult} that additionally supports AssertJ style assertions.
+ *
+ * Can be in two distinct states:
+ *
+ * The request processed successfully, and {@link #getUnresolvedException()}
+ * is therefore {@code null}.
+ * The request failed unexpectedly with {@link #getUnresolvedException()}
+ * providing more information about the error. Any attempt to access a
+ * member of the result fails with an exception.
+ *
+ *
+ * @author Stephane Nicoll
+ * @author Brian Clozel
+ * @since 6.2
+ * @see AssertableMockMvc
+ */
+public interface AssertableMvcResult extends MvcResult, AssertProvider {
+
+ /**
+ * Return the exception that was thrown unexpectedly while processing the
+ * request, if any.
+ */
+ @Nullable
+ Exception getUnresolvedException();
+
+}
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java
new file mode 100644
index 0000000000..3864688c9d
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.lang.Nullable;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.web.servlet.FlashMap;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.ModelAndView;
+
+/**
+ * The default {@link AssertableMvcResult} implementation.
+ *
+ * @author Stephane Nicoll
+ * @since 6.2
+ */
+final class DefaultAssertableMvcResult implements AssertableMvcResult {
+
+ @Nullable
+ private final MvcResult target;
+
+ @Nullable
+ private final Exception unresolvedException;
+
+ @Nullable
+ private final GenericHttpMessageConverter jsonMessageConverter;
+
+ DefaultAssertableMvcResult(@Nullable MvcResult target, @Nullable Exception unresolvedException, @Nullable GenericHttpMessageConverter jsonMessageConverter) {
+ this.target = target;
+ this.unresolvedException = unresolvedException;
+ this.jsonMessageConverter = jsonMessageConverter;
+ }
+
+ /**
+ * Return the exception that was thrown unexpectedly while processing the
+ * request, if any.
+ */
+ @Nullable
+ public Exception getUnresolvedException() {
+ return this.unresolvedException;
+ }
+
+ @Override
+ public MockHttpServletRequest getRequest() {
+ return getTarget().getRequest();
+ }
+
+ @Override
+ public MockHttpServletResponse getResponse() {
+ return getTarget().getResponse();
+ }
+
+ @Override
+ public Object getHandler() {
+ return getTarget().getHandler();
+ }
+
+ @Override
+ public HandlerInterceptor[] getInterceptors() {
+ return getTarget().getInterceptors();
+ }
+
+ @Override
+ public ModelAndView getModelAndView() {
+ return getTarget().getModelAndView();
+ }
+
+ @Override
+ public Exception getResolvedException() {
+ return getTarget().getResolvedException();
+ }
+
+ @Override
+ public FlashMap getFlashMap() {
+ return getTarget().getFlashMap();
+ }
+
+ @Override
+ public Object getAsyncResult() {
+ return getTarget().getAsyncResult();
+ }
+
+ @Override
+ public Object getAsyncResult(long timeToWait) {
+ return getTarget().getAsyncResult(timeToWait);
+ }
+
+
+ private MvcResult getTarget() {
+ if (this.target == null) {
+ throw new IllegalStateException(
+ "Request has failed with unresolved exception " + this.unresolvedException);
+ }
+ return this.target;
+ }
+
+ /**
+ * Use AssertJ's {@link org.assertj.core.api.Assertions#assertThat assertThat}
+ * instead.
+ */
+ @Override
+ public MvcResultAssert assertThat() {
+ return new MvcResultAssert(this, this.jsonMessageConverter);
+ }
+
+}
diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java
new file mode 100644
index 0000000000..147ec24792
--- /dev/null
+++ b/spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import java.io.BufferedReader;
+import java.io.PrintWriter;
+import java.io.StringReader;
+import java.io.StringWriter;
+
+import jakarta.servlet.http.Cookie;
+import org.assertj.core.api.AbstractStringAssert;
+import org.assertj.core.api.AbstractThrowableAssert;
+import org.assertj.core.api.Assertions;
+import org.assertj.core.api.MapAssert;
+import org.assertj.core.api.ObjectAssert;
+import org.assertj.core.error.BasicErrorMessageFactory;
+import org.assertj.core.internal.Failures;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.lang.Nullable;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.http.MediaTypeAssert;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.ResultHandler;
+import org.springframework.test.web.servlet.ResultMatcher;
+import org.springframework.web.servlet.ModelAndView;
+
+/**
+ * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied
+ * to {@link MvcResult}.
+ *
+ * @author Stephane Nicoll
+ * @author Brian Clozel
+ * @since 6.2
+ */
+public class MvcResultAssert extends AbstractMockHttpServletResponseAssert {
+
+ MvcResultAssert(AssertableMvcResult mvcResult, @Nullable GenericHttpMessageConverter jsonMessageConverter) {
+ super(jsonMessageConverter, mvcResult, MvcResultAssert.class);
+ }
+
+ @Override
+ protected MockHttpServletResponse getResponse() {
+ checkHasNotFailedUnexpectedly();
+ return this.actual.getResponse();
+ }
+
+ /**
+ * Verify that the request has failed with an unresolved exception, and
+ * return a new {@linkplain AbstractThrowableAssert assertion} object
+ * that uses the unresolved {@link Exception} as the object to test.
+ */
+ public AbstractThrowableAssert, ? extends Throwable> unresolvedException() {
+ hasUnresolvedException();
+ return Assertions.assertThat(this.actual.getUnresolvedException());
+ }
+
+ /**
+ * Return a new {@linkplain AbstractMockHttpServletRequestAssert assertion}
+ * object that uses the {@link MockHttpServletRequest} as the object to test.
+ */
+ public AbstractMockHttpServletRequestAssert> request() {
+ checkHasNotFailedUnexpectedly();
+ return new MockHttpRequestAssert(this.actual.getRequest());
+ }
+
+ /**
+ * Return a new {@linkplain CookieMapAssert assertion} object that uses the
+ * response's {@linkplain Cookie cookies} as the object to test.
+ */
+ public CookieMapAssert cookies() {
+ checkHasNotFailedUnexpectedly();
+ return new CookieMapAssert(this.actual.getResponse().getCookies());
+ }
+
+ /**
+ * Return a new {@linkplain MediaTypeAssert assertion} object that uses the
+ * response's {@linkplain MediaType content type} as the object to test.
+ */
+ public MediaTypeAssert contentType() {
+ checkHasNotFailedUnexpectedly();
+ return new MediaTypeAssert(this.actual.getResponse().getContentType());
+ }
+
+ /**
+ * Return a new {@linkplain HandlerResultAssert assertion} object that uses
+ * the handler as the object to test. For a method invocation on a
+ * controller, this is relative method handler
+ * Example:
+ * // Check that a GET to "/greet" is invoked on a "handleGreet" method name
+ * assertThat(mvc.perform(get("/greet")).handler().method().hasName("sayGreet");
+ *
+ */
+ public HandlerResultAssert handler() {
+ checkHasNotFailedUnexpectedly();
+ return new HandlerResultAssert(this.actual.getHandler());
+ }
+
+ /**
+ * Verify that a {@link ModelAndView} is available and return a new
+ * {@linkplain ModelAssert assertion} object that uses the
+ * {@linkplain ModelAndView#getModel() model} as the object to test.
+ */
+ public ModelAssert model() {
+ checkHasNotFailedUnexpectedly();
+ return new ModelAssert(getModelAndView().getModel());
+ }
+
+ /**
+ * Verify that a {@link ModelAndView} is available and return a new
+ * {@linkplain AbstractStringAssert assertion} object that uses the
+ * {@linkplain ModelAndView#getViewName()} view name} as the object to test.
+ * @see #hasViewName(String)
+ */
+ public AbstractStringAssert> viewName() {
+ checkHasNotFailedUnexpectedly();
+ return Assertions.assertThat(getModelAndView().getViewName()).as("View name");
+ }
+
+ /**
+ * Return a new {@linkplain MapAssert assertion} object that uses the
+ * "output" flash attributes saved during request processing as the object
+ * to test.
+ */
+ public MapAssert flash() {
+ checkHasNotFailedUnexpectedly();
+ return new MapAssert<>(this.actual.getFlashMap());
+ }
+
+ /**
+ * Verify that an {@linkplain AbstractHttpServletRequestAssert#hasAsyncStarted(boolean)
+ * asynchronous processing has started} and return a new
+ * {@linkplain ObjectAssert assertion} object that uses the asynchronous
+ * result as the object to test.
+ */
+ public ObjectAssert asyncResult() {
+ request().hasAsyncStarted(true);
+ return Assertions.assertThat(this.actual.getAsyncResult()).as("Async result");
+ }
+
+ /**
+ * Verify that the request has failed with an unresolved exception.
+ * @see #unresolvedException()
+ */
+ public MvcResultAssert hasUnresolvedException() {
+ Assertions.assertThat(this.actual.getUnresolvedException())
+ .withFailMessage("Expecting request to have failed but it has succeeded").isNotNull();
+ return this;
+ }
+
+ /**
+ * Verify that the request has not failed with an unresolved exception.
+ */
+ public MvcResultAssert doesNotHaveUnresolvedException() {
+ Assertions.assertThat(this.actual.getUnresolvedException())
+ .withFailMessage("Expecting request to have succeeded but it has failed").isNull();
+ return this;
+ }
+
+ /**
+ * Verify that the actual mvc result matches the given {@link ResultMatcher}.
+ * @param resultMatcher the result matcher to invoke
+ */
+ public MvcResultAssert matches(ResultMatcher resultMatcher) {
+ checkHasNotFailedUnexpectedly();
+ return super.satisfies(resultMatcher::match);
+ }
+
+ /**
+ * Apply the given {@link ResultHandler} to the actual mvc result.
+ * @param resultHandler the result matcher to invoke
+ */
+ public MvcResultAssert apply(ResultHandler resultHandler) {
+ checkHasNotFailedUnexpectedly();
+ return satisfies(resultHandler::handle);
+ }
+
+ /**
+ * Verify that a {@link ModelAndView} is available with a view equals to
+ * the given one. For more advanced assertions, consider using
+ * {@link #viewName()}
+ * @param viewName the expected view name
+ */
+ public MvcResultAssert hasViewName(String viewName) {
+ viewName().isEqualTo(viewName);
+ return this.myself;
+ }
+
+
+ private ModelAndView getModelAndView() {
+ ModelAndView modelAndView = this.actual.getModelAndView();
+ Assertions.assertThat(modelAndView).as("ModelAndView").isNotNull();
+ return modelAndView;
+ }
+
+ protected void checkHasNotFailedUnexpectedly() {
+ Exception unresolvedException = this.actual.getUnresolvedException();
+ if (unresolvedException != null) {
+ throw Failures.instance().failure(this.info,
+ new RequestFailedUnexpectedly(unresolvedException));
+ }
+ }
+
+ private static final class MockHttpRequestAssert extends AbstractMockHttpServletRequestAssert {
+
+ private MockHttpRequestAssert(MockHttpServletRequest request) {
+ super(request, MockHttpRequestAssert.class);
+ }
+ }
+
+ private static final class RequestFailedUnexpectedly extends BasicErrorMessageFactory {
+
+ private RequestFailedUnexpectedly(Exception ex) {
+ super("%nRequest has failed unexpectedly:%n%s", unquotedString(getIndentedStackTraceAsString(ex)));
+ }
+
+ private static String getIndentedStackTraceAsString(Throwable ex) {
+ String stackTrace = getStackTraceAsString(ex);
+ return indent(stackTrace);
+ }
+
+ private static String getStackTraceAsString(Throwable ex) {
+ StringWriter writer = new StringWriter();
+ PrintWriter printer = new PrintWriter(writer);
+ ex.printStackTrace(printer);
+ return writer.toString();
+ }
+
+ private static String indent(String input) {
+ BufferedReader reader = new BufferedReader(new StringReader(input));
+ StringWriter writer = new StringWriter();
+ PrintWriter printer = new PrintWriter(writer);
+ reader.lines().forEach(line -> {
+ printer.print(" ");
+ printer.println(line);
+ });
+ return writer.toString();
+ }
+
+ }
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java
new file mode 100644
index 0000000000..201579a993
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java
@@ -0,0 +1,558 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Size;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.test.context.web.WebAppConfiguration;
+import org.springframework.test.web.Person;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.ui.Model;
+import org.springframework.validation.Errors;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.SessionAttributes;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.servlet.DispatcherServlet;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.InstanceOfAssertFactories.map;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for {@link AssertableMockMvc}.
+ *
+ * @author Brian Clozel
+ * @author Stephane Nicoll
+ */
+@SpringJUnitConfig
+@WebAppConfiguration
+public class AssertableMockMvcIntegrationTests {
+
+ private final AssertableMockMvc mockMvc;
+
+ AssertableMockMvcIntegrationTests(WebApplicationContext wac) {
+ this.mockMvc = AssertableMockMvc.from(wac);
+ }
+
+ @Nested
+ class RequestTests {
+
+ @Test
+ void hasAsyncStartedTrue() {
+ assertThat(perform(get("/callable").accept(MediaType.APPLICATION_JSON)))
+ .request().hasAsyncStarted(true);
+ }
+
+ @Test
+ void hasAsyncStartedFalse() {
+ assertThat(perform(get("/greet"))).request().hasAsyncStarted(false);
+ }
+
+ @Test
+ void attributes() {
+ assertThat(perform(get("/greet"))).request().attributes()
+ .containsKey(DispatcherServlet.WEB_APPLICATION_CONTEXT_ATTRIBUTE);
+ }
+
+ @Test
+ void sessionAttributes() {
+ assertThat(perform(get("/locale"))).request().sessionAttributes()
+ .containsOnly(entry("locale", Locale.UK));
+ }
+ }
+
+ @Nested
+ class CookieTests {
+
+ @Test
+ void containsCookie() {
+ Cookie cookie = new Cookie("test", "value");
+ assertThat(performWithCookie(cookie, get("/greet"))).cookies().containsCookie("test");
+ }
+
+ @Test
+ void hasValue() {
+ Cookie cookie = new Cookie("test", "value");
+ assertThat(performWithCookie(cookie, get("/greet"))).cookies().hasValue("test", "value");
+ }
+
+ private AssertableMvcResult performWithCookie(Cookie cookie, MockHttpServletRequestBuilder request) {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(List.of(new TestController()), builder -> builder.addInterceptors(
+ new HandlerInterceptor() {
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ response.addCookie(cookie);
+ return true;
+ }
+ }).build());
+ return mockMvc.perform(request);
+ }
+ }
+
+ @Nested
+ class ContentTypeTests {
+
+ @Test
+ void contentType() {
+ assertThat(perform(get("/greet"))).contentType().isCompatibleWith("text/plain");
+ }
+
+ }
+
+ @Nested
+ class StatusTests {
+
+ @Test
+ void statusOk() {
+ assertThat(perform(get("/greet"))).hasStatusOk();
+ }
+
+ @Test
+ void statusSeries() {
+ assertThat(perform(get("/greet"))).hasStatus2xxSuccessful();
+ }
+
+ }
+
+ @Nested
+ class HeadersTests {
+
+ @Test
+ void shouldAssertHeader() {
+ assertThat(perform(get("/greet"))).headers()
+ .hasValue("Content-Type", "text/plain;charset=ISO-8859-1");
+ }
+
+ @Test
+ void shouldAssertHeaderWithCallback() {
+ assertThat(perform(get("/greet"))).headers().satisfies(textContent("ISO-8859-1"));
+ }
+
+ private Consumer textContent(String charset) {
+ return headers -> assertThat(headers).containsEntry(
+ "Content-Type", List.of("text/plain;charset=%s".formatted(charset)));
+ }
+
+ }
+
+ @Nested
+ class ModelAndViewTests {
+
+ @Test
+ void hasViewName() {
+ assertThat(perform(get("/persons/{0}", "Andy"))).hasViewName("persons/index");
+ }
+
+ @Test
+ void viewNameWithCustomAssertion() {
+ assertThat(perform(get("/persons/{0}", "Andy"))).viewName().startsWith("persons");
+ }
+
+ @Test
+ void containsAttributes() {
+ assertThat(perform(post("/persons").param("name", "Andy"))).model()
+ .containsOnlyKeys("name").containsEntry("name", "Andy");
+ }
+
+ @Test
+ void hasErrors() {
+ assertThat(perform(post("/persons"))).model().hasErrors();
+ }
+
+ @Test
+ void hasAttributeErrors() {
+ assertThat(perform(post("/persons"))).model().hasAttributeErrors("person");
+ }
+
+ @Test
+ void hasAttributeErrorsCount() {
+ assertThat(perform(post("/persons"))).model().extractingBindingResult("person").hasErrorsCount(1);
+ }
+
+ }
+
+ @Nested
+ class FlashTests {
+
+ @Test
+ void containsAttributes() {
+ assertThat(perform(post("/persons").param("name", "Andy"))).flash()
+ .containsOnlyKeys("message").hasEntrySatisfying("message",
+ value -> assertThat(value).isInstanceOfSatisfying(String.class,
+ stringValue -> assertThat(stringValue).startsWith("success")));
+ }
+ }
+
+ @Nested
+ class BodyTests {
+
+ @Test
+ void asyncResult() {
+ assertThat(perform(get("/callable").accept(MediaType.APPLICATION_JSON)))
+ .asyncResult().asInstanceOf(map(String.class, Object.class))
+ .containsOnly(entry("key", "value"));
+ }
+
+ @Test
+ void stringContent() {
+ assertThat(perform(get("/greet"))).body().asString().isEqualTo("hello");
+ }
+
+ @Test
+ void jsonPathContent() {
+ assertThat(perform(get("/message"))).body().jsonPath()
+ .extractingPath("$.message").asString().isEqualTo("hello");
+ }
+
+ @Test
+ void jsonContentCanLoadResourceFromClasspath() {
+ assertThat(perform(get("/message"))).body().json().isLenientlyEqualTo(
+ new ClassPathResource("message.json", AssertableMockMvcIntegrationTests.class));
+ }
+
+ @Test
+ void jsonContentUsingResourceLoaderClass() {
+ assertThat(perform(get("/message"))).body().json(AssertableMockMvcIntegrationTests.class)
+ .isLenientlyEqualTo("message.json");
+ }
+
+ }
+
+ @Nested
+ class HandlerTests {
+
+ @Test
+ void handlerOn404() {
+ assertThat(perform(get("/unknown-resource"))).handler().isNull();
+ }
+
+ @Test
+ void hasType() {
+ assertThat(perform(get("/greet"))).handler().hasType(TestController.class);
+ }
+
+ @Test
+ void isMethodHandler() {
+ assertThat(perform(get("/greet"))).handler().isMethodHandler();
+ }
+
+ @Test
+ void isInvokedOn() {
+ assertThat(perform(get("/callable"))).handler()
+ .isInvokedOn(AsyncController.class, AsyncController::getCallable);
+ }
+
+ }
+
+ @Nested
+ class ExceptionTests {
+
+ @Test
+ void doesNotHaveUnresolvedException() {
+ assertThat(perform(get("/greet"))).doesNotHaveUnresolvedException();
+ }
+
+ @Test
+ void hasUnresolvedException() {
+ assertThat(perform(get("/error/1"))).hasUnresolvedException();
+ }
+
+ @Test
+ void doesNotHaveUnresolvedExceptionWithUnresolvedException() {
+ assertThatExceptionOfType(AssertionError.class).isThrownBy(() ->
+ assertThat(perform(get("/error/1"))).doesNotHaveUnresolvedException())
+ .withMessage("Expecting request to have succeeded but it has failed");
+ }
+
+ @Test
+ void hasUnresolvedExceptionWithoutUnresolvedException() {
+ assertThatExceptionOfType(AssertionError.class).isThrownBy(() ->
+ assertThat(perform(get("/greet"))).hasUnresolvedException())
+ .withMessage("Expecting request to have failed but it has succeeded");
+ }
+
+ @Test
+ void unresolvedExceptionWithFailedRequest() {
+ assertThat(perform(get("/error/1"))).unresolvedException()
+ .isInstanceOf(ServletException.class)
+ .cause().isInstanceOf(IllegalStateException.class).hasMessage("Expected");
+ }
+
+ @Test
+ void unresolvedExceptionWithSuccessfulRequest() {
+ assertThatExceptionOfType(AssertionError.class).isThrownBy(() ->
+ assertThat(perform(get("/greet"))).unresolvedException())
+ .withMessage("Expecting request to have failed but it has succeeded");
+ }
+
+ // Check that assertions fail immediately if request has failed with unresolved exception
+
+ @Test
+ void assertAndApplyWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).apply(mvcResult -> {}));
+ }
+
+ @Test
+ void assertAsyncResultWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).asyncResult());
+ }
+
+ @Test
+ void assertContentTypeWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).contentType());
+ }
+
+ @Test
+ void assertCookiesWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).cookies());
+ }
+
+ @Test
+ void assertFlashWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).flash());
+ }
+
+ @Test
+ void assertStatusWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).hasStatus(3));
+ }
+
+ @Test
+ void assertHeaderWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).headers());
+ }
+
+ @Test
+ void assertViewNameWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).hasViewName("test"));
+ }
+
+ @Test
+ void assertForwardedUrlWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).hasForwardedUrl("test"));
+ }
+
+ @Test
+ void assertRedirectedUrlWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).hasRedirectedUrl("test"));
+ }
+
+ @Test
+ void assertRequestWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).request());
+ }
+
+ @Test
+ void assertModelWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).model());
+ }
+
+ @Test
+ void assertBodyWithUnresolvedException() {
+ testAssertionFailureWithUnresolvableException(
+ result -> assertThat(result).body());
+ }
+
+
+ private void testAssertionFailureWithUnresolvableException(Consumer assertions) {
+ AssertableMvcResult result = perform(get("/error/1"));
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertions.accept(result))
+ .withMessageContainingAll("Request has failed unexpectedly:",
+ ServletException.class.getName(), IllegalStateException.class.getName(),
+ "Expected");
+ }
+
+ }
+
+ @Test
+ void hasForwardUrl() {
+ assertThat(perform(get("/persons/John"))).hasForwardedUrl("persons/index");
+ }
+
+ @Test
+ void hasRedirectUrl() {
+ assertThat(perform(post("/persons").param("name", "Andy"))).hasStatus(HttpStatus.FOUND)
+ .hasRedirectedUrl("/persons/Andy");
+ }
+
+ @Test
+ void satisfiesAllowAdditionalAssertions() {
+ assertThat(this.mockMvc.perform(get("/greet"))).satisfies(result -> {
+ assertThat(result).isInstanceOf(MvcResult.class);
+ assertThat(result).hasStatusOk();
+ });
+ }
+
+ @Test
+ void resultMatcherCanBeReused() {
+ assertThat(this.mockMvc.perform(get("/greet"))).matches(status().isOk());
+ }
+
+ @Test
+ void resultMatcherFailsWithDedicatedException() {
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(() -> assertThat(this.mockMvc.perform(get("/greet")))
+ .matches(status().isNotFound()))
+ .withMessageContaining("Status expected:<404> but was:<200>");
+ }
+
+ @Test
+ void shouldApplyResultHandler() { // Spring RESTDocs example
+ AtomicBoolean applied = new AtomicBoolean();
+ assertThat(this.mockMvc.perform(get("/greet"))).apply(result -> applied.set(true));
+ assertThat(applied).isTrue();
+ }
+
+
+ private AssertableMvcResult perform(MockHttpServletRequestBuilder builder) {
+ return this.mockMvc.perform(builder);
+ }
+
+
+ @Configuration
+ @EnableWebMvc
+ @Import({ TestController.class, PersonController.class, AsyncController.class,
+ SessionController.class, ErrorController.class })
+ static class WebConfiguration {
+
+ }
+
+ @RestController
+ static class TestController {
+
+ @GetMapping(path = "/greet", produces = "text/plain")
+ String greet() {
+ return "hello";
+ }
+
+ @GetMapping(path = "/message", produces = MediaType.APPLICATION_JSON_VALUE)
+ String message() {
+ return "{\"message\": \"hello\"}";
+ }
+ }
+
+ @Controller
+ @RequestMapping("/persons")
+ static class PersonController {
+
+ @GetMapping("/{name}")
+ public String get(@PathVariable String name, Model model) {
+ model.addAttribute(new Person(name));
+ return "persons/index";
+ }
+
+ @PostMapping
+ String create(@Valid Person person, Errors errors, RedirectAttributes redirectAttrs) {
+ if (errors.hasErrors()) {
+ return "persons/add";
+ }
+ redirectAttrs.addAttribute("name", person.getName());
+ redirectAttrs.addFlashAttribute("message", "success!");
+ return "redirect:/persons/{name}";
+ }
+ }
+
+
+ @RestController
+ static class AsyncController {
+
+ @GetMapping("/callable")
+ public Callable> getCallable() {
+ return () -> Collections.singletonMap("key", "value");
+ }
+ }
+
+ @Controller
+ @SessionAttributes("locale")
+ private static class SessionController {
+
+ @ModelAttribute
+ void populate(Model model) {
+ model.addAttribute("locale", Locale.UK);
+ }
+
+ @RequestMapping("/locale")
+ String handle() {
+ return "view";
+ }
+ }
+
+ @Controller
+ private static class ErrorController {
+
+ @GetMapping("/error/1")
+ public String one() {
+ throw new IllegalStateException("Expected");
+ }
+
+ @GetMapping("/error/validation/{id}")
+ public String validation(@PathVariable @Size(max = 4) String id) {
+ return "Hello " + id;
+ }
+
+ }
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java
new file mode 100644
index 0000000000..c8548e7a5d
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.ServletException;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.context.annotation.AnnotationConfigUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.mock.web.MockServletContext;
+import org.springframework.test.json.JsonPathAssert;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.context.support.GenericWebApplicationContext;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+
+/**
+ * Tests for {@link AssertableMockMvc}.
+ *
+ * @author Stephane Nicoll
+ */
+class AssertableMockMvcTests {
+
+ private static final MappingJackson2HttpMessageConverter jsonHttpMessageConverter =
+ new MappingJackson2HttpMessageConverter(new ObjectMapper());
+
+ @Test
+ void createShouldRejectNullMockMvc() {
+ assertThatThrownBy(() -> AssertableMockMvc.create(null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void createWithExistingWebApplicationContext() {
+ try (GenericWebApplicationContext wac = create(WebConfiguration.class)) {
+ AssertableMockMvc mockMvc = AssertableMockMvc.from(wac);
+ assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 41");
+ assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 42");
+ }
+ }
+
+ @Test
+ void createWithControllerClassShouldInstantiateControllers() {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class, CounterController.class);
+ assertThat(mockMvc.perform(get("/hello"))).body().isEqualTo("Hello World");
+ assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 1");
+ assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 2");
+ }
+
+ @Test
+ void createWithControllersShouldUseThemAsIs() {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(new HelloController(),
+ new CounterController(new AtomicInteger(41)));
+ assertThat(mockMvc.perform(get("/hello"))).body().isEqualTo("Hello World");
+ assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 42");
+ assertThat(mockMvc.perform(post("/increase"))).body().isEqualTo("counter 43");
+ }
+
+ @Test
+ void createWithControllerAndCustomizations() {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(List.of(new HelloController()), builder ->
+ builder.defaultRequest(get("/hello").accept(MediaType.APPLICATION_JSON)).build());
+ assertThat(mockMvc.perform(get("/hello"))).hasStatus(HttpStatus.NOT_ACCEPTABLE);
+ }
+
+ @Test
+ void createWithControllersHasNoHttpMessageConverter() {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(new HelloController());
+ JsonPathAssert jsonPathAssert = assertThat(mockMvc.perform(get("/json"))).hasStatusOk().body().jsonPath();
+ assertThatIllegalStateException()
+ .isThrownBy(() -> jsonPathAssert.extractingPath("$").convertTo(Message.class))
+ .withMessageContaining("No JSON message converter available");
+ }
+
+ @Test
+ void createWithControllerCanConfigureHttpMessageConverters() {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class)
+ .withHttpMessageConverters(List.of(jsonHttpMessageConverter));
+ assertThat(mockMvc.perform(get("/json"))).hasStatusOk().body().jsonPath()
+ .extractingPath("$").convertTo(Message.class).satisfies(message -> {
+ assertThat(message.message()).isEqualTo("Hello World");
+ assertThat(message.counter()).isEqualTo(42);
+ });
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void withHttpMessageConverterDetectsJsonConverter() {
+ MappingJackson2HttpMessageConverter converter = spy(jsonHttpMessageConverter);
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class)
+ .withHttpMessageConverters(List.of(mock(GenericHttpMessageConverter.class),
+ mock(GenericHttpMessageConverter.class), converter));
+ assertThat(mockMvc.perform(get("/json"))).hasStatusOk().body().jsonPath()
+ .extractingPath("$").convertTo(Message.class).satisfies(message -> {
+ assertThat(message.message()).isEqualTo("Hello World");
+ assertThat(message.counter()).isEqualTo(42);
+ });
+ verify(converter).canWrite(Map.class, MediaType.APPLICATION_JSON);
+ }
+
+ @Test
+ void performWithUnresolvedExceptionSetsException() {
+ AssertableMockMvc mockMvc = AssertableMockMvc.of(HelloController.class);
+ AssertableMvcResult result = mockMvc.perform(get("/error"));
+ assertThat(result.getUnresolvedException()).isNotNull().isInstanceOf(ServletException.class)
+ .cause().isInstanceOf(IllegalStateException.class).hasMessage("Expected");
+ assertThat(result).hasFieldOrPropertyWithValue("target", null);
+ }
+
+ private GenericWebApplicationContext create(Class>... classes) {
+ GenericWebApplicationContext applicationContext = new GenericWebApplicationContext(
+ new MockServletContext());
+ AnnotationConfigUtils.registerAnnotationConfigProcessors(applicationContext);
+ for (Class> beanClass : classes) {
+ applicationContext.registerBean(beanClass);
+ }
+ applicationContext.refresh();
+ return applicationContext;
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableWebMvc
+ static class WebConfiguration {
+
+ @Bean
+ CounterController counterController() {
+ return new CounterController(new AtomicInteger(40));
+ }
+ }
+
+
+ @RestController
+ private static class HelloController {
+
+ @GetMapping(path = "/hello", produces = "text/plain")
+ public String hello() {
+ return "Hello World";
+ }
+
+ @GetMapping("/error")
+ public String error() {
+ throw new IllegalStateException("Expected");
+ }
+
+ @GetMapping(path = "/json", produces = "application/json")
+ public String json() {
+ return """
+ {
+ "message": "Hello World",
+ "counter": 42
+ }""";
+ }
+ }
+
+ private record Message(String message, int counter) {}
+
+ @RestController
+ private static class CounterController {
+
+ private final AtomicInteger counter;
+
+ public CounterController(AtomicInteger counter) {
+ this.counter = counter;
+ }
+
+ public CounterController() {
+ this(new AtomicInteger());
+ }
+
+ @PostMapping("/increase")
+ public String increase() {
+ int value = this.counter.incrementAndGet();
+ return "counter " + value;
+ }
+ }
+}
diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java
new file mode 100644
index 0000000000..f59a53521d
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2002-2024 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.assertj;
+
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.test.web.servlet.MvcResult;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link DefaultAssertableMvcResult}.
+ *
+ * @author Stephane Nicoll
+ */
+class DefaultAssertableMvcResultTests {
+
+ @Test
+ void createWithMvcResultDelegatesToIt() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ MvcResult mvcResult = mock(MvcResult.class);
+ given(mvcResult.getRequest()).willReturn(request);
+ DefaultAssertableMvcResult result = new DefaultAssertableMvcResult(mvcResult, null, null);
+ assertThat(result.getRequest()).isSameAs(request);
+ verify(mvcResult).getRequest();
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToRequest() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getRequest);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToResponse() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getResponse);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToHandler() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getHandler);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToInterceptors() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getInterceptors);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToModelAndView() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getModelAndView);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToResolvedException() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getResolvedException);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToFlashMap() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getFlashMap);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToAsyncResult() {
+ assertRequestHasFailed(DefaultAssertableMvcResult::getAsyncResult);
+ }
+
+ @Test
+ void createWithExceptionDoesNotAllowAccessToAsyncResultWithTimeToWait() {
+ assertRequestHasFailed(result -> result.getAsyncResult(1000));
+ }
+
+ @Test
+ void createWithExceptionReturnsException() {
+ IllegalStateException exception = new IllegalStateException("Expected");
+ DefaultAssertableMvcResult result = new DefaultAssertableMvcResult(null, exception, null);
+ assertThat(result.getUnresolvedException()).isSameAs(exception);
+ }
+
+ private void assertRequestHasFailed(Consumer action) {
+ DefaultAssertableMvcResult result = new DefaultAssertableMvcResult(null, new IllegalStateException("Expected"), null);
+ assertThatIllegalStateException().isThrownBy(() -> action.accept(result))
+ .withMessageContaining("Request has failed with unresolved exception");
+ }
+
+}
diff --git a/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json b/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json
new file mode 100644
index 0000000000..ff89222db7
--- /dev/null
+++ b/spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json
@@ -0,0 +1,3 @@
+{
+ "message": "hello"
+}
\ No newline at end of file