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 <brian.clozel@broadcom.com>
This commit is contained in:
Stéphane Nicoll
2024-03-15 13:30:16 +01:00
parent 555d4a6004
commit e7d7cb8641
8 changed files with 1536 additions and 0 deletions

View File

@@ -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}.
*
* <p>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}.
*
* <p>{@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<Object> jsonMessageConverter;
private AssertableMockMvc(MockMvc mockMvc, @Nullable GenericHttpMessageConverter<Object> 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., <em>refreshed</em>) {@link WebApplicationContext}. The
* given {@code customizations} are applied to the {@link DefaultMockMvcBuilder}
* that ultimately creates the underlying {@link MockMvc} instance.
* <p>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<DefaultMockMvcBuilder, MockMvc> 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., <em>refreshed</em>) {@link WebApplicationContext}.
* <p>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.
* <p>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<StandaloneMockMvcBuilder, MockMvc> 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.
* <p>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<HttpMessageConverter<?>> 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.
* <p>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:
* <pre><code class='java'>assertThat(mvc.perform(get("/greet")))
* .hasStatusOk()
* .body().asString().isEqualTo("Hello");
* </code></pre>
* <p>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:
* <pre><code class='java'>assertThat(mvc.perform(post("/boom")))
* .unresolvedException().isInstanceOf(IllegalStateException.class)
* .hasMessage("Expected");
* </code></pre>
* <p>
* @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<Object> findJsonMessageConverter(
Iterable<HttpMessageConverter<?>> 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);
}
}

View File

@@ -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.
*
* <p>Can be in two distinct states:
* <ol>
* <li>The request processed successfully, and {@link #getUnresolvedException()}
* is therefore {@code null}.</li>
* <li>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.</li>
* </ol>
*
* @author Stephane Nicoll
* @author Brian Clozel
* @since 6.2
* @see AssertableMockMvc
*/
public interface AssertableMvcResult extends MvcResult, AssertProvider<MvcResultAssert> {
/**
* Return the exception that was thrown unexpectedly while processing the
* request, if any.
*/
@Nullable
Exception getUnresolvedException();
}

View File

@@ -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<Object> jsonMessageConverter;
DefaultAssertableMvcResult(@Nullable MvcResult target, @Nullable Exception unresolvedException, @Nullable GenericHttpMessageConverter<Object> 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);
}
}

View File

@@ -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> {
MvcResultAssert(AssertableMvcResult mvcResult, @Nullable GenericHttpMessageConverter<Object> 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: <pre><code class='java'>
* // Check that a GET to "/greet" is invoked on a "handleGreet" method name
* assertThat(mvc.perform(get("/greet")).handler().method().hasName("sayGreet");
* </code></pre>
*/
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<String, Object> 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<Object> 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<MockHttpRequestAssert> {
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();
}
}
}