Introduce ApplicationContextFailureProcessor SPI in the TCF

This commit introduces an ApplicationContextFailureProcessor SPI in the
Spring TestContext Framework that allows third parties to process
failures that occur while a SmartContextLoader attempts to load an
ApplicationContext.

SmartContextLoader implementations must introduce a try-catch block
around the loading code and throw a ContextLoadException that wraps
the failed ApplicationContext and the cause of the failure.

Extensions of AbstractTestContextBootstrapper can configure an
ApplicationContextFailureProcessor by overriding the new protected
getApplicationContextFailureProcessor() method.

DefaultCacheAwareContextLoaderDelegate unwraps any ContextLoadException
and delegates to the configured ApplicationContextFailureProcessor for
processing.

Closes gh-28826
This commit is contained in:
Sam Brannen 2022-10-18 13:03:14 +02:00
parent 19f795a75a
commit 6bdf0bcc4a
11 changed files with 391 additions and 53 deletions

View File

@ -0,0 +1,41 @@
/*
* 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;
import org.springframework.context.ApplicationContext;
/**
* Strategy for components that process failures related to application contexts
* within the <em>Spring TestContext Framework</em>.
*
* @author Sam Brannen
* @since 6.0
* @see ContextLoadException
*/
public interface ApplicationContextFailureProcessor {
/**
* Invoked when a failure was encountered while attempting to load an
* {@link ApplicationContext}.
* <p>Implementations of this method must not throw any exceptions. Consequently,
* any exception thrown by an implementation of this method will be ignored.
* @param context the application context that did not load successfully
* @param exception the exception caught while loading the application context
*/
void processLoadFailure(ApplicationContext context, Throwable exception);
}

View File

@ -75,6 +75,7 @@ public interface CacheAwareContextLoaderDelegate {
* the application context
* @see #isContextLoaded
* @see #closeContext
* @see #setContextFailureProcessor
*/
ApplicationContext loadContext(MergedContextConfiguration mergedContextConfiguration);
@ -100,4 +101,18 @@ public interface CacheAwareContextLoaderDelegate {
*/
void closeContext(MergedContextConfiguration mergedContextConfiguration, @Nullable HierarchyMode hierarchyMode);
/**
* Set the {@link ApplicationContextFailureProcessor} to use.
* <p>The default implementation ignores the supplied processor.
* <p>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
}
}

View File

@ -0,0 +1,60 @@
/*
* 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;
import org.springframework.context.ApplicationContext;
/**
* Exception thrown when an error occurs while a {@link SmartContextLoader}
* attempts to load an {@link ApplicationContext}.
*
* <p>This exception provides access to the {@linkplain #getApplicationContext()
* application context} that failed to load as well as the {@linkplain #getCause()
* exception} caught while attempting to load that context.
*
* @author Sam Brannen
* @since 6.0
* @see SmartContextLoader#loadContext(MergedContextConfiguration)
*/
@SuppressWarnings("serial")
public class ContextLoadException extends Exception {
private final ApplicationContext applicationContext;
/**
* Create a new {@code ContextLoadException} for the supplied
* {@link ApplicationContext} and {@link Exception}.
* @param applicationContext the application context that failed to load
* @param cause the exception caught while attempting to load that context
*/
public ContextLoadException(ApplicationContext applicationContext, Exception cause) {
super(cause);
this.applicationContext = applicationContext;
}
/**
* Get the {@code ApplicationContext} that failed to load.
* <p>Clients must not retain a long-lived reference to the context returned
* from this method.
*/
public ApplicationContext getApplicationContext() {
return this.applicationContext;
}
}

View File

@ -126,10 +126,23 @@ public interface SmartContextLoader extends ContextLoader {
* closed on JVM shutdown. This allows for freeing of external resources held
* by beans within the context &mdash; for example, temporary files.</li>
* </ul>
* <p>As of Spring Framework 6.0, any exception thrown while attempting to
* load an {@code ApplicationContext} should be wrapped in a
* {@link ContextLoadException}. Concrete implementations should therefore
* contain a try-catch block similar to the following.
* <pre style="code">
* ApplicationContext context = // create context
* try {
* // configure and refresh context
* }
* catch (Exception ex) {
* throw new ContextLoadException(context, ex);
* }
* </pre>
* @param mergedConfig the merged context configuration to use to load the
* application context
* @return a new application context
* @throws Exception if context loading failed
* @throws ContextLoadException if context loading failed
* @see #processContextConfiguration(ContextConfigurationAttributes)
* @see #loadContextForAotProcessing(MergedContextConfiguration)
* @see org.springframework.context.annotation.AnnotationConfigUtils#registerAnnotationConfigProcessors(org.springframework.beans.factory.support.BeanDefinitionRegistry)

View File

@ -19,6 +19,7 @@ package org.springframework.test.context.aot;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.test.context.ContextLoadException;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.SmartContextLoader;
@ -52,10 +53,22 @@ public interface AotContextLoader extends SmartContextLoader {
* {@linkplain org.springframework.context.ConfigurableApplicationContext#registerShutdownHook()
* register a JVM shutdown hook} for it. Otherwise, this method should implement
* behavior identical to {@code loadContext(MergedContextConfiguration)}.
* <p>Any exception thrown while attempting to load an {@code ApplicationContext}
* should be wrapped in a {@link ContextLoadException}. Concrete implementations
* should therefore contain a try-catch block similar to the following.
* <pre style="code">
* GenericApplicationContext context = // create context
* try {
* // configure context
* }
* catch (Exception ex) {
* throw new ContextLoadException(context, ex);
* }
* </pre>
* @param mergedConfig the merged context configuration to use to load the
* application context
* @return a new {@code GenericApplicationContext}
* @throws Exception if context loading failed
* @throws ContextLoadException if context loading failed
* @see #loadContextForAotRuntime(MergedContextConfiguration, ApplicationContextInitializer)
*/
ApplicationContext loadContextForAotProcessing(MergedContextConfiguration mergedConfig) throws Exception;
@ -67,12 +80,24 @@ public interface AotContextLoader extends SmartContextLoader {
* <p>This method must instantiate, initialize, and
* {@linkplain org.springframework.context.ConfigurableApplicationContext#refresh()
* refresh} the {@code ApplicationContext}.
* <p>Any exception thrown while attempting to load an {@code ApplicationContext}
* should be wrapped in a {@link ContextLoadException}. Concrete implementations
* should therefore contain a try-catch block similar to the following.
* <pre style="code">
* GenericApplicationContext context = // create context
* try {
* // configure and refresh context
* }
* catch (Exception ex) {
* throw new ContextLoadException(context, ex);
* }
* </pre>
* @param mergedConfig the merged context configuration to use to load the
* application context
* @param initializer the {@code ApplicationContextInitializer} that should
* be applied to the context in order to recreate bean definitions
* @return a new {@code GenericApplicationContext}
* @throws Exception if context loading failed
* @throws ContextLoadException if context loading failed
* @see #loadContextForAotProcessing(MergedContextConfiguration)
*/
ApplicationContext loadContextForAotRuntime(MergedContextConfiguration mergedConfig,

View File

@ -39,6 +39,7 @@ import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.log.LogMessage;
import org.springframework.javapoet.ClassName;
import org.springframework.test.context.BootstrapUtils;
import org.springframework.test.context.ContextLoadException;
import org.springframework.test.context.ContextLoader;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.SmartContextLoader;
@ -217,9 +218,10 @@ public class TestContextAotGenerator {
}
}
catch (Exception ex) {
Throwable cause = (ex instanceof ContextLoadException cle ? cle.getCause() : ex);
throw new TestContextAotException(
"Failed to load ApplicationContext for AOT processing for test class [%s]"
.formatted(testClass.getName()), ex);
.formatted(testClass.getName()), cause);
}
}
throw new TestContextAotException("""

View File

@ -26,7 +26,9 @@ import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.log.LogMessage;
import org.springframework.lang.Nullable;
import org.springframework.test.annotation.DirtiesContext.HierarchyMode;
import org.springframework.test.context.ApplicationContextFailureProcessor;
import org.springframework.test.context.CacheAwareContextLoaderDelegate;
import org.springframework.test.context.ContextLoadException;
import org.springframework.test.context.ContextLoader;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.SmartContextLoader;
@ -59,6 +61,9 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext
private final ContextCache contextCache;
@Nullable
private ApplicationContextFailureProcessor contextFailureProcessor;
/**
* Construct a new {@code DefaultCacheAwareContextLoaderDelegate} using
@ -110,8 +115,23 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext
this.contextCache.put(mergedContextConfiguration, context);
}
catch (Exception ex) {
Throwable cause = ex;
if (ex instanceof ContextLoadException cle) {
cause = cle.getCause();
if (this.contextFailureProcessor != null) {
try {
this.contextFailureProcessor.processLoadFailure(cle.getApplicationContext(), cause);
}
catch (Throwable throwable) {
if (logger.isDebugEnabled()) {
logger.debug("Ignoring exception thrown from ApplicationContextFailureProcessor [%s]: %s"
.formatted(this.contextFailureProcessor, throwable));
}
}
}
}
throw new IllegalStateException(
"Failed to load ApplicationContext for " + mergedContextConfiguration, ex);
"Failed to load ApplicationContext for " + mergedContextConfiguration, cause);
}
}
else {
@ -134,6 +154,12 @@ public class DefaultCacheAwareContextLoaderDelegate implements CacheAwareContext
}
}
@Override
public void setContextFailureProcessor(ApplicationContextFailureProcessor contextFailureProcessor) {
this.contextFailureProcessor = contextFailureProcessor;
}
/**
* Get the {@link ContextCache} used by this context loader delegate.
*/

View File

@ -28,6 +28,7 @@ import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.AnnotationConfigUtils;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.context.ContextLoadException;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.aot.AotContextLoader;
import org.springframework.util.Assert;
@ -159,13 +160,18 @@ public abstract class AbstractGenericContextLoader extends AbstractContextLoader
validateMergedContextConfiguration(mergedConfig);
GenericApplicationContext context = createContext();
prepareContext(context);
prepareContext(context, mergedConfig);
initializer.initialize(context);
customizeContext(context);
customizeContext(context, mergedConfig);
context.refresh();
return context;
try {
prepareContext(context);
prepareContext(context, mergedConfig);
initializer.initialize(context);
customizeContext(context);
customizeContext(context, mergedConfig);
context.refresh();
return context;
}
catch (Exception ex) {
throw new ContextLoadException(context, ex);
}
}
/**
@ -189,25 +195,30 @@ public abstract class AbstractGenericContextLoader extends AbstractContextLoader
validateMergedContextConfiguration(mergedConfig);
GenericApplicationContext context = createContext();
ApplicationContext parent = mergedConfig.getParentApplicationContext();
if (parent != null) {
context.setParent(parent);
try {
ApplicationContext parent = mergedConfig.getParentApplicationContext();
if (parent != null) {
context.setParent(parent);
}
prepareContext(context);
prepareContext(context, mergedConfig);
customizeBeanFactory(context.getDefaultListableBeanFactory());
loadBeanDefinitions(context, mergedConfig);
AnnotationConfigUtils.registerAnnotationConfigProcessors(context);
customizeContext(context);
customizeContext(context, mergedConfig);
if (!forAotProcessing) {
context.refresh();
context.registerShutdownHook();
}
return context;
}
prepareContext(context);
prepareContext(context, mergedConfig);
customizeBeanFactory(context.getDefaultListableBeanFactory());
loadBeanDefinitions(context, mergedConfig);
AnnotationConfigUtils.registerAnnotationConfigProcessors(context);
customizeContext(context);
customizeContext(context, mergedConfig);
if (!forAotProcessing) {
context.refresh();
context.registerShutdownHook();
catch (Exception ex) {
throw new ContextLoadException(context, ex);
}
return context;
}
/**

View File

@ -35,6 +35,7 @@ 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;
@ -494,15 +495,40 @@ public abstract class AbstractTestContextBootstrapper implements TestContextBoot
/**
* Get the {@link CacheAwareContextLoaderDelegate} to use for transparent
* interaction with the {@code ContextCache}.
* <p>The default implementation simply delegates to
* {@code getBootstrapContext().getCacheAwareContextLoaderDelegate()}.
* <p>The default implementation delegates to
* {@code getBootstrapContext().getCacheAwareContextLoaderDelegate()} and
* supplies the returned delegate the configured
* {@link #getApplicationContextFailureProcessor() ApplicationContextFailureProcessor}.
* <p>Concrete subclasses may choose to override this method to return a custom
* {@code CacheAwareContextLoaderDelegate} implementation with custom
* {@link org.springframework.test.context.cache.ContextCache ContextCache} support.
* @return the context loader delegate (never {@code null})
* @see #getApplicationContextFailureProcessor()
*/
protected CacheAwareContextLoaderDelegate getCacheAwareContextLoaderDelegate() {
return getBootstrapContext().getCacheAwareContextLoaderDelegate();
CacheAwareContextLoaderDelegate delegate = getBootstrapContext().getCacheAwareContextLoaderDelegate();
ApplicationContextFailureProcessor contextFailureProcessor = getApplicationContextFailureProcessor();
if (contextFailureProcessor != null) {
delegate.setContextFailureProcessor(contextFailureProcessor);
}
return delegate;
}
/**
* Get the {@link ApplicationContextFailureProcessor} to use.
* <p>The default implementation returns {@code null}.
* <p>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;
}
/**

View File

@ -29,6 +29,7 @@ import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.FileSystemResourceLoader;
import org.springframework.core.io.ResourceLoader;
import org.springframework.mock.web.MockServletContext;
import org.springframework.test.context.ContextLoadException;
import org.springframework.test.context.MergedContextConfiguration;
import org.springframework.test.context.aot.AotContextLoader;
import org.springframework.test.context.support.AbstractContextLoader;
@ -156,12 +157,17 @@ public abstract class AbstractGenericWebContextLoader extends AbstractContextLoa
validateMergedContextConfiguration(webMergedConfig);
GenericWebApplicationContext context = createContext();
configureWebResources(context, webMergedConfig);
prepareContext(context, webMergedConfig);
initializer.initialize(context);
customizeContext(context, webMergedConfig);
context.refresh();
return context;
try {
configureWebResources(context, webMergedConfig);
prepareContext(context, webMergedConfig);
initializer.initialize(context);
customizeContext(context, webMergedConfig);
context.refresh();
return context;
}
catch (Exception ex) {
throw new ContextLoadException(context, ex);
}
}
/**
@ -194,24 +200,28 @@ public abstract class AbstractGenericWebContextLoader extends AbstractContextLoa
validateMergedContextConfiguration(webMergedConfig);
GenericWebApplicationContext context = createContext();
try {
ApplicationContext parent = mergedConfig.getParentApplicationContext();
if (parent != null) {
context.setParent(parent);
}
configureWebResources(context, webMergedConfig);
prepareContext(context, webMergedConfig);
customizeBeanFactory(context.getDefaultListableBeanFactory(), webMergedConfig);
loadBeanDefinitions(context, webMergedConfig);
AnnotationConfigUtils.registerAnnotationConfigProcessors(context);
customizeContext(context, webMergedConfig);
ApplicationContext parent = mergedConfig.getParentApplicationContext();
if (parent != null) {
context.setParent(parent);
if (!forAotProcessing) {
context.refresh();
context.registerShutdownHook();
}
return context;
}
configureWebResources(context, webMergedConfig);
prepareContext(context, webMergedConfig);
customizeBeanFactory(context.getDefaultListableBeanFactory(), webMergedConfig);
loadBeanDefinitions(context, webMergedConfig);
AnnotationConfigUtils.registerAnnotationConfigProcessors(context);
customizeContext(context, webMergedConfig);
if (!forAotProcessing) {
context.refresh();
context.registerShutdownHook();
catch (Exception ex) {
throw new ContextLoadException(context, ex);
}
return context;
}
/**

View File

@ -0,0 +1,109 @@
/*
* 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.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.platform.testkit.engine.EngineTestKit;
import org.springframework.beans.BeanInstantiationException;
import org.springframework.beans.factory.BeanCreationException;
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.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;
/**
* Tests for failures that occur while loading an {@link ApplicationContext}.
*
* @author Sam Brannen
* @since 6.0
*/
class ContextLoadFailureTests {
static List<LoadFailure> loadFailures = new ArrayList<>();
@BeforeEach
@AfterEach
void clearFailures() {
loadFailures.clear();
}
@Test
void customBootstrapperAppliesApplicationContextFailureProcessor() {
assertThat(loadFailures).isEmpty();
EngineTestKit.engine("junit-jupiter")
.selectors(selectClass(ExplosiveContextTestCase.class))//
.execute()
.testEvents()
.assertStatistics(stats -> stats.started(1).succeeded(0).failed(1));
assertThat(loadFailures).hasSize(1);
LoadFailure loadFailure = loadFailures.get(0);
assertThat(loadFailure.context()).isExactlyInstanceOf(GenericApplicationContext.class);
assertThat(loadFailure.exception())
.isInstanceOf(BeanCreationException.class)
.cause().isInstanceOf(BeanInstantiationException.class)
.rootCause().isInstanceOf(StackOverflowError.class).hasMessage("Boom!");
}
@FailingTestCase
@SpringJUnitConfig
@BootstrapWith(CustomTestContextBootstrapper.class)
static class ExplosiveContextTestCase {
@Test
void test1() {
/* no-op */
}
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean
String explosion() {
throw new StackOverflowError("Boom!");
}
}
}
static class CustomTestContextBootstrapper extends DefaultTestContextBootstrapper {
@Override
protected ApplicationContextFailureProcessor getApplicationContextFailureProcessor() {
return (context, exception) -> loadFailures.add(new LoadFailure(context, exception));
}
}
record LoadFailure(ApplicationContext context, Throwable exception) {}
}