diff --git a/spring-test/src/main/java/org/springframework/test/util/ExceptionCollector.java b/spring-test/src/main/java/org/springframework/test/util/ExceptionCollector.java new file mode 100644 index 00000000000..1c5ffaa3323 --- /dev/null +++ b/spring-test/src/main/java/org/springframework/test/util/ExceptionCollector.java @@ -0,0 +1,127 @@ +/* + * 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.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * {@code ExceptionCollector} is a test utility for executing code blocks, + * collecting exceptions, and generating a single {@link AssertionError} + * containing any exceptions encountered as {@linkplain Throwable#getSuppressed() + * suppressed exceptions}. + * + *
This utility is intended to support soft assertion use cases
+ * similar to the {@code SoftAssertions} support in AssertJ and the
+ * {@code assertAll()} support in JUnit Jupiter.
+ *
+ * @author Sam Brannen
+ * @since 5.3.10
+ */
+public class ExceptionCollector {
+
+ private final List If this collector is empty, this method is effectively a no-op.
+ * If this collector contains a single {@link Error} or {@link Exception},
+ * this method rethrows the error or exception.
+ * If this collector contains a single {@link Throwable}, this method throws
+ * an {@link AssertionError} with the error message of the {@code Throwable}
+ * and with the {@code Throwable} as the {@linkplain Throwable#getCause() cause}.
+ * If this collector contains multiple exceptions, this method throws an
+ * {@code AssertionError} whose message is "Multiple Exceptions (#):"
+ * followed by a new line with the error message of each exception separated
+ * by a new line, with {@code #} replaced with the number of exceptions present.
+ * In addition, each exception will be added to the {@code AssertionError} as
+ * a {@link Throwable#addSuppressed(Throwable) suppressed exception}.
+ * @see #execute(Executable)
+ * @see #getExceptions()
+ */
+ public void assertEmpty() throws Exception {
+ if (this.exceptions.isEmpty()) {
+ return;
+ }
+
+ if (this.exceptions.size() == 1) {
+ Throwable exception = this.exceptions.get(0);
+ if (exception instanceof Error) {
+ throw (Error) exception;
+ }
+ if (exception instanceof Exception) {
+ throw (Exception) exception;
+ }
+ AssertionError assertionError = new AssertionError(exception.getMessage());
+ assertionError.initCause(exception);
+ throw assertionError;
+ }
+
+ StringBuilder message = new StringBuilder();
+ message.append("Multiple Exceptions (").append(this.exceptions.size()).append("):");
+ for (Throwable exception : this.exceptions) {
+ message.append('\n');
+ message.append(exception.getMessage());
+ }
+ AssertionError assertionError = new AssertionError(message);
+ this.exceptions.forEach(assertionError::addSuppressed);
+ throw assertionError;
+ }
+
+
+ /**
+ * {@code Executable} is a functional interface that can be used to implement
+ * any generic block of code that potentially throws a {@link Throwable}.
+ *
+ * The {@code Executable} interface is similar to {@link java.lang.Runnable},
+ * except that an {@code Executable} can throw any kind of exception.
+ */
+ @FunctionalInterface
+ interface Executable {
+
+ void execute() throws Throwable;
+
+ }
+
+}
diff --git a/spring-test/src/test/java/org/springframework/test/util/ExceptionCollectorTests.java b/spring-test/src/test/java/org/springframework/test/util/ExceptionCollectorTests.java
new file mode 100644
index 00000000000..6bb39d407b0
--- /dev/null
+++ b/spring-test/src/test/java/org/springframework/test/util/ExceptionCollectorTests.java
@@ -0,0 +1,151 @@
+/*
+ * 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.util;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.test.util.ExceptionCollector.Executable;
+
+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 ExceptionCollector}.
+ *
+ * @author Sam Brannen
+ * @since 5.3.10
+ */
+public class ExceptionCollectorTests {
+
+ private static final char EOL = '\n';
+
+ private final ExceptionCollector collector = new ExceptionCollector();
+
+
+ @Test
+ void noExceptions() {
+ this.collector.execute(() -> {});
+
+ assertThat(this.collector.getExceptions()).isEmpty();
+ assertThatNoException().isThrownBy(this.collector::assertEmpty);
+ }
+
+ @Test
+ void oneError() {
+ this.collector.execute(error());
+
+ assertOneFailure(Error.class, "error");
+ }
+
+ @Test
+ void oneAssertionError() {
+ this.collector.execute(assertionError());
+
+ assertOneFailure(AssertionError.class, "assertion");
+ }
+
+ @Test
+ void oneCheckedException() {
+ this.collector.execute(checkedException());
+
+ assertOneFailure(Exception.class, "checked");
+ }
+
+ @Test
+ void oneUncheckedException() {
+ this.collector.execute(uncheckedException());
+
+ assertOneFailure(RuntimeException.class, "unchecked");
+ }
+
+ @Test
+ void oneThrowable() {
+ this.collector.execute(throwable());
+
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(this.collector::assertEmpty)
+ .withMessage("throwable")
+ .withCauseExactlyInstanceOf(Throwable.class)
+ .satisfies(error -> assertThat(error.getCause()).hasMessage("throwable"))
+ .satisfies(error -> assertThat(error).hasNoSuppressedExceptions());
+ }
+
+ private void assertOneFailure(Class extends Throwable> expectedType, String failureMessage) {
+ assertThatExceptionOfType(expectedType)
+ .isThrownBy(this.collector::assertEmpty)
+ .satisfies(exception ->
+ assertThat(exception)
+ .isExactlyInstanceOf(expectedType)
+ .hasNoSuppressedExceptions()
+ .hasNoCause()
+ .hasMessage(failureMessage));
+ }
+
+ @Test
+ void multipleFailures() {
+ this.collector.execute(assertionError());
+ this.collector.execute(checkedException());
+ this.collector.execute(uncheckedException());
+ this.collector.execute(error());
+ this.collector.execute(throwable());
+
+ assertThatExceptionOfType(AssertionError.class)
+ .isThrownBy(this.collector::assertEmpty)
+ .withMessage("Multiple Exceptions (5):" + EOL + //
+ "assertion" + EOL + //
+ "checked" + EOL + //
+ "unchecked" + EOL + //
+ "error" + EOL + //
+ "throwable"//
+ )
+ .satisfies(exception ->
+ assertThat(exception.getSuppressed()).extracting(Object::getClass).map(Class::getSimpleName)
+ .containsExactly("AssertionError", "Exception", "RuntimeException", "Error", "Throwable"));
+ }
+
+ private Executable throwable() {
+ return () -> {
+ throw new Throwable("throwable");
+ };
+ }
+
+ private Executable error() {
+ return () -> {
+ throw new Error("error");
+ };
+ }
+
+ private Executable assertionError() {
+ return () -> {
+ throw new AssertionError("assertion");
+ };
+ }
+
+ private Executable checkedException() {
+ return () -> {
+ throw new Exception("checked");
+ };
+ }
+
+ private Executable uncheckedException() {
+ return () -> {
+ throw new RuntimeException("unchecked");
+ };
+ }
+
+}