From e7d7cb86417d8115795e6cfa2683f268f970ceb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Fri, 15 Mar 2024 13:30:16 +0100 Subject: [PATCH] Add AssertJ support for MockMvc This commit adds a new way to use MockMvc by returning a MvcResult that is AssertJ compatible. Compared to the existing MockMvc infrastructure, this new model has the following advantages: * Can be created from a MockMvc instance * Dedicated builder methods for easier setup * Assertions use familiar AssertJ syntax with discoverable assertions through a DSL * Improved exception message See gh-21178 Co-authored-by: Brian Clozel --- .../servlet/assertj/AssertableMockMvc.java | 227 +++++++ .../servlet/assertj/AssertableMvcResult.java | 50 ++ .../assertj/DefaultAssertableMvcResult.java | 123 ++++ .../web/servlet/assertj/MvcResultAssert.java | 258 ++++++++ .../AssertableMockMvcIntegrationTests.java | 558 ++++++++++++++++++ .../assertj/AssertableMockMvcTests.java | 210 +++++++ .../DefaultAssertableMvcResultTests.java | 107 ++++ .../test/web/servlet/assertj/message.json | 3 + 8 files changed, 1536 insertions(+) create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMockMvc.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/AssertableMvcResult.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResult.java create mode 100644 spring-test/src/main/java/org/springframework/test/web/servlet/assertj/MvcResultAssert.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcIntegrationTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/AssertableMockMvcTests.java create mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/assertj/DefaultAssertableMvcResultTests.java create mode 100644 spring-test/src/test/resources/org/springframework/test/web/servlet/assertj/message.json 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: + *

    + *
  1. The request processed successfully, and {@link #getUnresolvedException()} + * is therefore {@code null}.
  2. + *
  3. 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.
  4. + *
+ * + * @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 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