diff --git a/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java b/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java index d7086c3ae8..57ab6d7019 100644 --- a/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java +++ b/spring-test/src/main/java/org/springframework/test/context/CacheAwareContextLoaderDelegate.java @@ -101,18 +101,4 @@ public interface CacheAwareContextLoaderDelegate { */ void closeContext(MergedContextConfiguration mergedContextConfiguration, @Nullable HierarchyMode hierarchyMode); - /** - * Set the {@link ApplicationContextFailureProcessor} to use. - *

The default implementation ignores the supplied processor. - *

Concrete implementations should override this method to store a reference - * to the supplied processor and use it to process {@link ContextLoadException - * ContextLoadExceptions} thrown from context loaders in - * {@link #loadContext(MergedContextConfiguration)}. - * @param contextFailureProcessor the context failure processor to use - * @since 6.0 - */ - default void setContextFailureProcessor(ApplicationContextFailureProcessor contextFailureProcessor) { - // no-op - } - } diff --git a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java index fea89f7462..f0a94eb71d 100644 --- a/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java +++ b/spring-test/src/main/java/org/springframework/test/context/cache/DefaultCacheAwareContextLoaderDelegate.java @@ -16,6 +16,10 @@ package org.springframework.test.context.cache; +import java.lang.reflect.InvocationTargetException; +import java.util.Collection; +import java.util.List; + import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -23,6 +27,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.lang.Nullable; import org.springframework.test.annotation.DirtiesContext.HierarchyMode; import org.springframework.test.context.ApplicationContextFailureProcessor; @@ -44,6 +49,11 @@ import org.springframework.util.Assert; * invoke the {@link #DefaultCacheAwareContextLoaderDelegate(ContextCache)} * and provide a custom {@link ContextCache} implementation. * + *

As of Spring Framework 6.0, this class loads {@link ApplicationContextFailureProcessor} + * implementations via the {@link SpringFactoriesLoader} mechanism and delegates to + * them in {@link #loadContext(MergedContextConfiguration)} to process context + * load failures. + * * @author Sam Brannen * @since 4.1 */ @@ -51,6 +61,9 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext private static final Log logger = LogFactory.getLog(DefaultCacheAwareContextLoaderDelegate.class); + private static List contextFailureProcessors = + getApplicationContextFailureProcessors(); + /** * Default static cache of Spring application contexts. */ @@ -60,9 +73,6 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext private final ContextCache contextCache; - @Nullable - private ApplicationContextFailureProcessor contextFailureProcessor; - /** * Construct a new {@code DefaultCacheAwareContextLoaderDelegate} using @@ -117,14 +127,14 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext Throwable cause = ex; if (ex instanceof ContextLoadException cle) { cause = cle.getCause(); - if (this.contextFailureProcessor != null) { + for (ApplicationContextFailureProcessor contextFailureProcessor : contextFailureProcessors) { try { - this.contextFailureProcessor.processLoadFailure(cle.getApplicationContext(), cause); + contextFailureProcessor.processLoadFailure(cle.getApplicationContext(), cause); } catch (Throwable throwable) { if (logger.isDebugEnabled()) { logger.debug("Ignoring exception thrown from ApplicationContextFailureProcessor [%s]: %s" - .formatted(this.contextFailureProcessor, throwable)); + .formatted(contextFailureProcessor, throwable)); } } } @@ -153,12 +163,6 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext } } - @Override - public void setContextFailureProcessor(ApplicationContextFailureProcessor contextFailureProcessor) { - this.contextFailureProcessor = contextFailureProcessor; - } - - /** * Get the {@link ContextCache} used by this context loader delegate. */ @@ -236,6 +240,7 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext * unmodified. *

This allows for transparent {@link org.springframework.test.context.cache.ContextCache ContextCache} * support for AOT-optimized application contexts. + * @since 6.0 */ @SuppressWarnings("unchecked") private MergedContextConfiguration replaceIfNecessary(MergedContextConfiguration mergedConfig) { @@ -248,4 +253,71 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext return mergedConfig; } + /** + * Get the {@link ApplicationContextFailureProcessor} implementations to use, + * loaded via the {@link SpringFactoriesLoader} mechanism. + * @return the context failure processors to use + * @since 6.0 + */ + private static List getApplicationContextFailureProcessors() { + SpringFactoriesLoader loader = SpringFactoriesLoader.forDefaultResourceLocation( + DefaultCacheAwareContextLoaderDelegate.class.getClassLoader()); + List processors = loader.load(ApplicationContextFailureProcessor.class, + DefaultCacheAwareContextLoaderDelegate::handleInstantiationFailure); + if (logger.isTraceEnabled()) { + logger.trace("Loaded default ApplicationContextFailureProcessor implementations from location [%s]: %s" + .formatted(SpringFactoriesLoader.FACTORIES_RESOURCE_LOCATION, classNames(processors))); + } + else if (logger.isDebugEnabled()) { + logger.debug("Loaded default ApplicationContextFailureProcessor implementations from location [%s]: %s" + .formatted(SpringFactoriesLoader.FACTORIES_RESOURCE_LOCATION, classSimpleNames(processors))); + } + return processors; + } + + private static void handleInstantiationFailure( + Class factoryType, String factoryImplementationName, Throwable failure) { + + Throwable ex = (failure instanceof InvocationTargetException ite ? + ite.getTargetException() : failure); + if (ex instanceof ClassNotFoundException || ex instanceof NoClassDefFoundError) { + logSkippedComponent(factoryType, factoryImplementationName, ex); + } + else if (ex instanceof LinkageError) { + if (logger.isDebugEnabled()) { + logger.debug(""" + Could not load %1$s [%2$s]. Specify custom %1$s classes or make the default %1$s classes \ + available.""".formatted(factoryType.getSimpleName(), factoryImplementationName), ex); + } + } + else { + if (ex instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (ex instanceof Error error) { + throw error; + } + throw new IllegalStateException( + "Failed to load %s [%s].".formatted(factoryType.getSimpleName(), factoryImplementationName), ex); + } + } + + private static void logSkippedComponent(Class factoryType, String factoryImplementationName, Throwable ex) { + if (logger.isDebugEnabled()) { + logger.debug(""" + Skipping candidate %1$s [%2$s] due to a missing dependency. \ + Specify custom %1$s classes or make the default %1$s classes \ + and their required dependencies available. Offending class: [%3$s]""" + .formatted(factoryType.getSimpleName(), factoryImplementationName, ex.getMessage())); + } + } + + private static List classNames(Collection components) { + return components.stream().map(Object::getClass).map(Class::getName).toList(); + } + + private static List classSimpleNames(Collection components) { + return components.stream().map(Object::getClass).map(Class::getSimpleName).toList(); + } + } diff --git a/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java b/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java index 1519df9cd8..40b1f16081 100644 --- a/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java +++ b/spring-test/src/main/java/org/springframework/test/context/support/AbstractTestContextBootstrapper.java @@ -35,7 +35,6 @@ import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.core.io.support.SpringFactoriesLoader.FailureHandler; import org.springframework.lang.Nullable; -import org.springframework.test.context.ApplicationContextFailureProcessor; import org.springframework.test.context.BootstrapContext; import org.springframework.test.context.CacheAwareContextLoaderDelegate; import org.springframework.test.context.ContextConfiguration; @@ -523,29 +522,7 @@ public abstract class AbstractTestContextBootstrapper implements TestContextBoot * @see #getApplicationContextFailureProcessor() */ protected CacheAwareContextLoaderDelegate getCacheAwareContextLoaderDelegate() { - CacheAwareContextLoaderDelegate delegate = getBootstrapContext().getCacheAwareContextLoaderDelegate(); - ApplicationContextFailureProcessor contextFailureProcessor = getApplicationContextFailureProcessor(); - if (contextFailureProcessor != null) { - delegate.setContextFailureProcessor(contextFailureProcessor); - } - return delegate; - } - - /** - * Get the {@link ApplicationContextFailureProcessor} to use. - *

The default implementation returns {@code null}. - *

Concrete subclasses may choose to override this method to provide an - * {@code ApplicationContextFailureProcessor} that will be supplied to the - * configured {@code CacheAwareContextLoaderDelegate} in - * {@link #getCacheAwareContextLoaderDelegate()}. - * @return the context failure processor to use, or {@code null} if no processor - * should be used - * @since 6.0 - * @see #getCacheAwareContextLoaderDelegate() - */ - @Nullable - protected ApplicationContextFailureProcessor getApplicationContextFailureProcessor() { - return null; + return getBootstrapContext().getCacheAwareContextLoaderDelegate(); } /** diff --git a/spring-test/src/test/java/org/springframework/test/context/failures/ContextLoadFailureTests.java b/spring-test/src/test/java/org/springframework/test/context/failures/ContextLoadFailureTests.java index bb40c7cf34..c7331dbbc4 100644 --- a/spring-test/src/test/java/org/springframework/test/context/failures/ContextLoadFailureTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/failures/ContextLoadFailureTests.java @@ -16,9 +16,6 @@ package org.springframework.test.context.failures; -import java.util.ArrayList; -import java.util.List; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,11 +27,9 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.support.GenericApplicationContext; -import org.springframework.test.context.ApplicationContextFailureProcessor; -import org.springframework.test.context.BootstrapWith; +import org.springframework.test.context.failures.TrackingApplicationContextFailureProcessor.LoadFailure; import org.springframework.test.context.junit.jupiter.FailingTestCase; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.context.support.DefaultTestContextBootstrapper; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; @@ -47,18 +42,15 @@ import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass */ class ContextLoadFailureTests { - static List loadFailures = new ArrayList<>(); - - @BeforeEach @AfterEach void clearFailures() { - loadFailures.clear(); + TrackingApplicationContextFailureProcessor.loadFailures.clear(); } @Test void customBootstrapperAppliesApplicationContextFailureProcessor() { - assertThat(loadFailures).isEmpty(); + assertThat(TrackingApplicationContextFailureProcessor.loadFailures).isEmpty(); EngineTestKit.engine("junit-jupiter") .selectors(selectClass(ExplosiveContextTestCase.class))// @@ -66,8 +58,8 @@ class ContextLoadFailureTests { .testEvents() .assertStatistics(stats -> stats.started(1).succeeded(0).failed(1)); - assertThat(loadFailures).hasSize(1); - LoadFailure loadFailure = loadFailures.get(0); + assertThat(TrackingApplicationContextFailureProcessor.loadFailures).hasSize(1); + LoadFailure loadFailure = TrackingApplicationContextFailureProcessor.loadFailures.get(0); assertThat(loadFailure.context()).isExactlyInstanceOf(GenericApplicationContext.class); assertThat(loadFailure.exception()) .isInstanceOf(BeanCreationException.class) @@ -78,7 +70,6 @@ class ContextLoadFailureTests { @FailingTestCase @SpringJUnitConfig - @BootstrapWith(CustomTestContextBootstrapper.class) static class ExplosiveContextTestCase { @Test @@ -96,14 +87,4 @@ class ContextLoadFailureTests { } } - static class CustomTestContextBootstrapper extends DefaultTestContextBootstrapper { - - @Override - protected ApplicationContextFailureProcessor getApplicationContextFailureProcessor() { - return (context, exception) -> loadFailures.add(new LoadFailure(context, exception)); - } - } - - record LoadFailure(ApplicationContext context, Throwable exception) {} - } diff --git a/spring-test/src/test/java/org/springframework/test/context/failures/TrackingApplicationContextFailureProcessor.java b/spring-test/src/test/java/org/springframework/test/context/failures/TrackingApplicationContextFailureProcessor.java new file mode 100644 index 0000000000..45421e0c63 --- /dev/null +++ b/spring-test/src/test/java/org/springframework/test/context/failures/TrackingApplicationContextFailureProcessor.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2022 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.context.failures; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ApplicationContextFailureProcessor; + +/** + * Demo {@link ApplicationContextFailureProcessor} for tests that tracks + * {@linkplain LoadFailure load failures} that can be queried via + * {@link #loadFailures}. + * + * @author Sam Brannen + * @since 6.0 + */ +public class TrackingApplicationContextFailureProcessor implements ApplicationContextFailureProcessor { + + public static List loadFailures = Collections.synchronizedList(new ArrayList<>()); + + + @Override + public void processLoadFailure(ApplicationContext context, Throwable exception) { + loadFailures.add(new LoadFailure(context, exception)); + } + + public record LoadFailure(ApplicationContext context, Throwable exception) {} + +} diff --git a/spring-test/src/test/resources/META-INF/spring.factories b/spring-test/src/test/resources/META-INF/spring.factories index b26a9de824..cd50c180c2 100644 --- a/spring-test/src/test/resources/META-INF/spring.factories +++ b/spring-test/src/test/resources/META-INF/spring.factories @@ -1,6 +1,10 @@ -# Test configuration file containing a non-existent default TestExecutionListener and a demo ContextCustomizerFactory. +# Test configuration file containing a non-existent default TestExecutionListener, +# a demo ContextCustomizerFactory, and a demo ApplicationContextFailureProcessor. org.springframework.test.context.TestExecutionListener = org.example.FooListener org.springframework.test.context.ContextCustomizerFactory =\ org.springframework.test.context.aot.samples.basic.ImportsContextCustomizerFactory + +org.springframework.test.context.ApplicationContextFailureProcessor =\ + org.springframework.test.context.failures.TrackingApplicationContextFailureProcessor