From 6408f67a621aa217022d8cf088e9bec8c34cb00a Mon Sep 17 00:00:00 2001 From: Sam Brannen Date: Mon, 23 Aug 2021 13:05:50 +0200 Subject: [PATCH] Introduce ResultActions.andExpectAll() for soft assertions in MockMvc See gh-26917 --- .../test/web/servlet/ResultActions.java | 65 ++++++---- .../test/web/servlet/ResultMatcher.java | 29 +---- .../test/web/servlet/ResultMatcherTests.java | 117 ------------------ .../samples/context/JavaConfigTests.java | 40 +++++- src/docs/asciidoc/testing.adoc | 20 ++- 5 files changed, 102 insertions(+), 169 deletions(-) delete mode 100644 spring-test/src/test/java/org/springframework/test/web/servlet/ResultMatcherTests.java diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/ResultActions.java b/spring-test/src/main/java/org/springframework/test/web/servlet/ResultActions.java index 8259a19508b7..f4fd6da14b58 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/ResultActions.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/ResultActions.java @@ -16,6 +16,8 @@ package org.springframework.test.web.servlet; +import org.springframework.test.util.ExceptionCollector; + /** * Allows applying actions, such as expectations, on the result of an executed * request. @@ -25,6 +27,8 @@ * {@link org.springframework.test.web.servlet.result.MockMvcResultHandlers}. * * @author Rossen Stoyanchev + * @author Sam Brannen + * @author Michał Rowicki * @since 3.2 */ public interface ResultActions { @@ -32,9 +36,9 @@ public interface ResultActions { /** * Perform an expectation. * - *

Examples

- * - *

You can invoke {@code andExpect()} multiple times. + *

Example

+ *

You can invoke {@code andExpect()} multiple times as in the following + * example. *

 	 * // static imports: MockMvcRequestBuilders.*, MockMvcResultMatchers.*
 	 *
@@ -44,33 +48,48 @@ public interface ResultActions {
 	 *   .andExpect(jsonPath("$.person.name").value("Jason"));
 	 * 
* - *

You can provide all matchers as a var-arg list with {@code matchAll()}. + * @see #andExpectAll(ResultMatcher...) + */ + ResultActions andExpect(ResultMatcher matcher) throws Exception; + + /** + * Perform multiple expectations, with the guarantee that all expectations + * will be asserted even if one or more expectations fail with an exception. + *

If a single {@link Error} or {@link Exception} is thrown, it will + * be rethrown. + *

If multiple exceptions are thrown, this method will throw an + * {@link AssertionError} whose error message is a summary of all of the + * exceptions. In addition, each exception will be added as a + * {@linkplain Throwable#addSuppressed(Throwable) suppressed exception} to + * the {@code AssertionError}. + *

This feature is similar to the {@code SoftAssertions} support in AssertJ + * and the {@code assertAll()} support in JUnit Jupiter. + * + *

Example

+ *

Instead of invoking {@code andExpect()} multiple times, you can invoke + * {@code andExpectAll()} as in the following example. *

-	 * // static imports: MockMvcRequestBuilders.*, MockMvcResultMatchers.*, ResultMatcher.matchAll
+	 * // static imports: MockMvcRequestBuilders.*, MockMvcResultMatchers.*
 	 *
-	 * mockMvc.perform(post("/form"))
-	 *   .andExpect(matchAll(
+	 * mockMvc.perform(get("/person/1"))
+	 *   .andExpectAll(
 	 *       status().isOk(),
-	 *       redirectedUrl("/person/1"),
-	 *   	 model().size(1),
-	 *       model().attributeExists("person"),
-	 *       flash().attributeCount(1),
-	 *       flash().attribute("message", "success!"))
+	 *       content().contentType(MediaType.APPLICATION_JSON),
+	 *       jsonPath("$.person.name").value("Jason")
 	 *   );
 	 * 
* - *

Alternatively, you can provide all matchers to be evaluated using - * soft assertions with {@code matchAllSoftly()}. - *

-	 * // static imports: MockMvcRequestBuilders.*, MockMvcResultMatchers.*, ResultMatcher.matchAllSoftly
-	 * mockMvc.perform(post("/form"))
-	 *   .andExpect(matchAllSoftly(
-	 *       status().isOk(),
-	 *       redirectedUrl("/person/1"))
-	 *   );
-	 * 
+ * @since 5.3.10 + * @see #andExpect(ResultMatcher) */ - ResultActions andExpect(ResultMatcher matcher) throws Exception; + default ResultActions andExpectAll(ResultMatcher... matchers) throws Exception { + ExceptionCollector exceptionCollector = new ExceptionCollector(); + for (ResultMatcher matcher : matchers) { + exceptionCollector.execute(() -> this.andExpect(matcher)); + } + exceptionCollector.assertEmpty(); + return this; + } /** * Perform a general action. diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/ResultMatcher.java b/spring-test/src/main/java/org/springframework/test/web/servlet/ResultMatcher.java index ac40b9d71619..44aae68e150c 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/ResultMatcher.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/ResultMatcher.java @@ -16,8 +16,6 @@ package org.springframework.test.web.servlet; -import org.springframework.test.util.ExceptionCollector; - /** * A {@code ResultMatcher} matches the result of an executed request against * some expectation. @@ -40,13 +38,13 @@ * MockMvc mockMvc = webAppContextSetup(wac).build(); * * mockMvc.perform(get("/form")) - * .andExpect(status().isOk()) - * .andExpect(content().mimeType(MediaType.APPLICATION_JSON)); + * .andExpectAll( + * status().isOk(), + * content().mimeType(MediaType.APPLICATION_JSON)); * * * @author Rossen Stoyanchev * @author Sam Brannen - * @author Michał Rowicki * @since 3.2 */ @FunctionalInterface @@ -64,7 +62,10 @@ public interface ResultMatcher { * Static method for matching with an array of result matchers. * @param matchers the matchers * @since 5.1 + * @deprecated as of Spring Framework 5.3.10, in favor of + * {@link ResultActions#andExpectAll(ResultMatcher...)} */ + @Deprecated static ResultMatcher matchAll(ResultMatcher... matchers) { return result -> { for (ResultMatcher matcher : matchers) { @@ -73,22 +74,4 @@ static ResultMatcher matchAll(ResultMatcher... matchers) { }; } - /** - * Static method for matching with an array of result matchers whose assertion - * failures are caught and stored. Once all matchers have been called, if any - * failures occurred, an {@link AssertionError} will be thrown containing the - * error messages of all assertion failures. - * @param matchers the matchers - * @since 5.3.10 - */ - static ResultMatcher matchAllSoftly(ResultMatcher... matchers) { - return result -> { - ExceptionCollector exceptionCollector = new ExceptionCollector(); - for (ResultMatcher matcher : matchers) { - exceptionCollector.execute(() -> matcher.match(result)); - } - exceptionCollector.assertEmpty(); - }; - } - } diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/ResultMatcherTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/ResultMatcherTests.java deleted file mode 100644 index dd7cd3527cd5..000000000000 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/ResultMatcherTests.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2002-2021 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; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatNoException; - -/** - * Unit tests for {@link ResultMatcher}. - * - * @author Michał Rowicki - * @author Sam Brannen - * @since 5.3.10 - */ -class ResultMatcherTests { - - private static final String EOL = "\n"; - - private final StubMvcResult stubMvcResult = new StubMvcResult(null, null, null, null, null, null, null); - - - @Test - void softAssertionsWithNoFailures() { - ResultMatcher resultMatcher = ResultMatcher.matchAllSoftly(this::doNothing); - - assertThatNoException().isThrownBy(() -> resultMatcher.match(stubMvcResult)); - } - - @Test - void softAssertionsWithOneAssertionError() { - String failureMessage = "error"; - ResultMatcher resultMatcher = ResultMatcher.matchAllSoftly(assertionErrorMatcher(failureMessage)); - - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> resultMatcher.match(stubMvcResult)) - .withMessage(failureMessage) - .withNoCause() - .satisfies(error -> assertThat(error).hasNoSuppressedExceptions()); - } - - @Test - void softAssertionsWithOneRuntimeException() { - String failureMessage = "exception"; - ResultMatcher resultMatcher = ResultMatcher.matchAllSoftly(uncheckedExceptionMatcher(failureMessage)); - - assertThatExceptionOfType(RuntimeException.class) - .isThrownBy(() -> resultMatcher.match(stubMvcResult)) - .withMessage(failureMessage) - .withNoCause() - .satisfies(error -> assertThat(error).hasNoSuppressedExceptions()); - } - - @Test - void softAssertionsWithOneCheckedException() { - String failureMessage = "exception"; - ResultMatcher resultMatcher = ResultMatcher.matchAllSoftly(checkedExceptionMatcher(failureMessage)); - - assertThatExceptionOfType(Exception.class) - .isThrownBy(() -> resultMatcher.match(stubMvcResult)) - .withMessage(failureMessage) - .withNoCause() - .satisfies(exception -> assertThat(exception).hasNoSuppressedExceptions()); - } - - @Test - void softAssertionsWithTwoFailures() { - String firstFailure = "firstFailure"; - String secondFailure = "secondFailure"; - String thirdFailure = "thirdFailure"; - ResultMatcher resultMatcher = ResultMatcher.matchAllSoftly(assertionErrorMatcher(firstFailure), - checkedExceptionMatcher(secondFailure), uncheckedExceptionMatcher(thirdFailure)); - - assertThatExceptionOfType(AssertionError.class) - .isThrownBy(() -> resultMatcher.match(stubMvcResult)) - .withMessage("Multiple Exceptions (3):" + EOL + firstFailure + EOL + secondFailure + EOL + thirdFailure) - .satisfies(error -> assertThat(error.getSuppressed()).hasSize(3)); - } - - private ResultMatcher assertionErrorMatcher(String failureMessage) { - return result -> { - throw new AssertionError(failureMessage); - }; - } - - private ResultMatcher uncheckedExceptionMatcher(String failureMessage) { - return result -> { - throw new RuntimeException(failureMessage); - }; - } - - private ResultMatcher checkedExceptionMatcher(String failureMessage) { - return result -> { - throw new Exception(failureMessage); - }; - } - - void doNothing(MvcResult mvcResult) { - } - -} diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java index e96c0c1f6cbe..9cde95fe2ce3 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/context/JavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2021 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. @@ -47,11 +47,13 @@ import org.springframework.web.servlet.view.tiles3.TilesConfigurer; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -61,6 +63,7 @@ * @author Rossen Stoyanchev * @author Sam Brannen * @author Sebastien Deleuze + * @author Michał Rowicki */ @ExtendWith(SpringExtension.class) @WebAppConfiguration("classpath:META-INF/web-resources") @@ -93,9 +96,38 @@ public void setup() { public void person() throws Exception { this.mockMvc.perform(get("/person/5").accept(MediaType.APPLICATION_JSON)) .andDo(print()) - .andExpect(status().isOk()) - .andExpect(request().asyncNotStarted()) - .andExpect(content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}")); + .andExpectAll( + status().isOk(), + request().asyncNotStarted(), + content().string("{\"name\":\"Joe\",\"someDouble\":0.0,\"someBoolean\":false}"), + jsonPath("$.name").value("Joe") + ); + } + + @Test + public void andExpectAllWithOneFailure() { + assertThatExceptionOfType(AssertionError.class) + .isThrownBy(() -> this.mockMvc.perform(get("/person/5").accept(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isBadGateway(), + request().asyncNotStarted(), + jsonPath("$.name").value("Joe"))) + .withMessage("Status expected:<502> but was:<200>") + .satisfies(error -> assertThat(error).hasNoSuppressedExceptions()); + } + + @Test + public void andExpectAllWithMultipleFailures() { + assertThatExceptionOfType(AssertionError.class).isThrownBy(() -> + this.mockMvc.perform(get("/person/5").accept(MediaType.APPLICATION_JSON)) + .andExpectAll( + status().isBadGateway(), + request().asyncNotStarted(), + jsonPath("$.name").value("Joe"), + jsonPath("$.name").value("Jane") + )) + .withMessage("Multiple Exceptions (2):\nStatus expected:<502> but was:<200>\nJSON path \"$.name\" expected: but was:") + .satisfies(error -> assertThat(error.getSuppressed()).hasSize(2)); } @Test diff --git a/src/docs/asciidoc/testing.adoc b/src/docs/asciidoc/testing.adoc index 90c463fbe6fa..bc3af10d0891 100644 --- a/src/docs/asciidoc/testing.adoc +++ b/src/docs/asciidoc/testing.adoc @@ -7219,8 +7219,9 @@ they must be specified on every request. [[spring-mvc-test-server-defining-expectations]] ===== Defining Expectations -You can define expectations by appending one or more `.andExpect(..)` calls after -performing a request, as the following example shows: +You can define expectations by appending one or more `andExpect(..)` calls after +performing a request, as the following example shows. As soon as one expectation fails, +no other expectations will be asserted. [source,java,indent=0,subs="verbatim,quotes",role="primary"] .Java @@ -7240,6 +7241,21 @@ performing a request, as the following example shows: } ---- +You can define multiple expectations by appending `andExpectAll(..)` after performing a +request, as the following example shows. In contrast to `andExpect(..)`, +`andExpectAll(..)` guarantees that all supplied expectations will be asserted and that +all failures will be tracked and reported. + +[source,java,indent=0,subs="verbatim,quotes",role="primary"] +.Java +---- + // static import of MockMvcRequestBuilders.* and MockMvcResultMatchers.* + + mockMvc.perform(get("/accounts/1")).andExpectAll( + status().isOk(), + content().contentType("application/json;charset=UTF-8")); +---- + `MockMvcResultMatchers.*` provides a number of expectations, some of which are further nested with more detailed expectations.