Support declarative ContextCustomizerFactory registration in the TCF

Prior to this commit, it was only possible to register a
ContextCustomizerFactory in the TestContext framework (TCF) via the
SpringFactoriesLoader mechanism.

This commit introduces support for declarative registration of a
ContextCustomizerFactory local to a test class via a new
@ContextCustomizerFactories annotation.

Closes gh-26148
This commit is contained in:
Sam Brannen
2023-06-30 13:29:12 +02:00
parent 99a50e7cea
commit a220c545fc
27 changed files with 1085 additions and 70 deletions

View File

@@ -34,6 +34,7 @@ import org.springframework.context.ConfigurableApplicationContext;
* @author Sam Brannen
* @since 4.3
* @see ContextCustomizerFactory
* @see ContextCustomizerFactories @ContextCustomizerFactories
* @see org.springframework.test.context.support.AbstractContextLoader#customizeContext
*/
@FunctionalInterface

View File

@@ -0,0 +1,125 @@
/*
* Copyright 2002-2023 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 java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* {@code @ContextCustomizerFactories} defines class-level metadata for configuring
* which {@link ContextCustomizerFactory} implementations should be registered with
* the <em>Spring TestContext Framework</em>.
*
* <p>{@code @ContextCustomizerFactories} is used to register factories for a
* particular test class, its subclasses, and its nested classes. If you wish to
* register a factory globally, you should register it via the automatic discovery
* mechanism described in {@link ContextCustomizerFactory}.
*
* <p>This annotation may be used as a <em>meta-annotation</em> to create custom
* <em>composed annotations</em>. In addition, this annotation will be inherited
* from an enclosing test class by default. See
* {@link NestedTestConfiguration @NestedTestConfiguration} for details.
*
* @author Sam Brannen
* @since 6.1
* @see ContextCustomizerFactory
* @see ContextCustomizer
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface ContextCustomizerFactories {
/**
* Alias for {@link #factories}.
* <p>This attribute may <strong>not</strong> be used in conjunction with
* {@link #factories}, but it may be used instead of {@link #factories}.
*/
@AliasFor("factories")
Class<? extends ContextCustomizerFactory>[] value() default {};
/**
* The {@link ContextCustomizerFactory} implementations to register.
* <p>This attribute may <strong>not</strong> be used in conjunction with
* {@link #value}, but it may be used instead of {@link #value}.
*/
@AliasFor("value")
Class<? extends ContextCustomizerFactory>[] factories() default {};
/**
* Whether the configured set of {@link #factories} from superclasses and
* enclosing classes should be <em>inherited</em>.
* <p>The default value is {@code true}, which means that an annotated class
* will <em>inherit</em> the factories defined by an annotated superclass or
* enclosing class. Specifically, the factories for an annotated class will be
* appended to the list of factories defined by an annotated superclass or
* enclosing class. Thus, subclasses and nested classes have the option of
* <em>extending</em> the list of factories.
* <p>If {@code inheritListeners} is set to {@code false}, the factories for
* the annotated class will <em>shadow</em> and effectively replace any
* factories defined by a superclass or enclosing class.
*/
boolean inheritFactories() default true;
/**
* The <em>merge mode</em> to use when {@code @ContextCustomizerFactories} is
* declared on a class that does <strong>not</strong> inherit factories from
* a superclass or enclosing class.
* <p>Can be set to {@link MergeMode#REPLACE_DEFAULTS REPLACE_DEFAULTS} to
* have locally declared factories replace the default factories.
* <p>The mode is ignored if factories are inherited from a superclass or
* enclosing class.
* <p>Defaults to {@link MergeMode#MERGE_WITH_DEFAULTS MERGE_WITH_DEFAULTS}.
* @see MergeMode
*/
MergeMode mergeMode() default MergeMode.MERGE_WITH_DEFAULTS;
/**
* Enumeration of <em>modes</em> that dictate whether explicitly declared
* factories are merged with the default factories when
* {@code @ContextCustomizerFactories} is declared on a class that does
* <strong>not</strong> inherit factories from a superclass or enclosing
* class.
*/
enum MergeMode {
/**
* Indicates that locally declared factories should be merged with the
* default factories.
* <p>The merging algorithm ensures that duplicates are removed from the
* list and that locally declared factories are appended to the list of
* default factories when merged.
*/
MERGE_WITH_DEFAULTS,
/**
* Indicates that locally declared factories should replace the default
* factories.
*/
REPLACE_DEFAULTS
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2023 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.
@@ -29,12 +29,18 @@ import org.springframework.lang.Nullable;
*
* <p>By default, the Spring TestContext Framework will use the
* {@link org.springframework.core.io.support.SpringFactoriesLoader SpringFactoriesLoader}
* mechanism for loading factories configured in all {@code META-INF/spring.factories}
* mechanism for loading default factories configured in all {@code META-INF/spring.factories}
* files on the classpath.
*
* <p>As of Spring Framework 6.1, it is also possible to register factories
* declaratively via the {@link ContextCustomizerFactories @ContextCustomizerFactories}
* annotation.
*
* @author Phillip Webb
* @author Sam Brannen
* @since 4.3
* @see ContextCustomizer
* @see ContextCustomizerFactories @ContextCustomizerFactories
*/
@FunctionalInterface
public interface ContextCustomizerFactory {

View File

@@ -37,6 +37,7 @@ import org.springframework.test.context.CacheAwareContextLoaderDelegate;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.ContextConfigurationAttributes;
import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.ContextCustomizerFactories;
import org.springframework.test.context.ContextCustomizerFactory;
import org.springframework.test.context.ContextHierarchy;
import org.springframework.test.context.ContextLoader;
@@ -378,7 +379,7 @@ public abstract class AbstractTestContextBootstrapper implements TestContextBoot
private Set<ContextCustomizer> getContextCustomizers(Class<?> testClass,
List<ContextConfigurationAttributes> configAttributes) {
List<ContextCustomizerFactory> factories = getContextCustomizerFactories();
List<ContextCustomizerFactory> factories = getContextCustomizerFactories(testClass);
Set<ContextCustomizer> customizers = new LinkedHashSet<>(factories.size());
for (ContextCustomizerFactory factory : factories) {
ContextCustomizer customizer = factory.createContextCustomizer(testClass, configAttributes);
@@ -397,6 +398,69 @@ public abstract class AbstractTestContextBootstrapper implements TestContextBoot
return customizers;
}
private List<ContextCustomizerFactory> getContextCustomizerFactories(Class<?> testClass) {
AnnotationDescriptor<ContextCustomizerFactories> descriptor =
TestContextAnnotationUtils.findAnnotationDescriptor(testClass, ContextCustomizerFactories.class);
List<ContextCustomizerFactory> factories = new ArrayList<>();
if (descriptor == null) {
if (logger.isTraceEnabled()) {
logger.trace("@ContextCustomizerFactories is not present for class [%s]"
.formatted(testClass.getName()));
}
factories.addAll(getContextCustomizerFactories());
}
else {
// Traverse the class hierarchy...
while (descriptor != null) {
Class<?> declaringClass = descriptor.getDeclaringClass();
ContextCustomizerFactories annotation = descriptor.getAnnotation();
if (logger.isTraceEnabled()) {
logger.trace("Retrieved %s for declaring class [%s]."
.formatted(annotation, declaringClass.getName()));
}
boolean inheritFactories = annotation.inheritFactories();
AnnotationDescriptor<ContextCustomizerFactories> parentDescriptor = descriptor.next();
factories.addAll(0, instantiateCustomizerFactories(annotation.factories()));
// If there are no factories to inherit, we might need to merge the
// locally declared factories with the defaults.
if ((!inheritFactories || parentDescriptor == null) &&
annotation.mergeMode() == ContextCustomizerFactories.MergeMode.MERGE_WITH_DEFAULTS) {
if (logger.isTraceEnabled()) {
logger.trace(String.format("Merging default factories with factories configured via " +
"@ContextCustomizerFactories for class [%s].", descriptor.getRootDeclaringClass().getName()));
}
factories.addAll(0, getContextCustomizerFactories());
}
descriptor = (inheritFactories ? parentDescriptor : null);
}
}
// Remove possible duplicates.
List<ContextCustomizerFactory> uniqueFactories = new ArrayList<>(factories.size());
factories.forEach(factory -> {
Class<? extends ContextCustomizerFactory> factoryClass = factory.getClass();
if (uniqueFactories.stream().map(Object::getClass).noneMatch(factoryClass::equals)) {
uniqueFactories.add(factory);
}
});
factories = uniqueFactories;
if (logger.isTraceEnabled()) {
logger.trace("Using ContextCustomizerFactory implementations for test class [%s]: %s"
.formatted(testClass.getName(), factories));
}
else if (logger.isDebugEnabled()) {
logger.debug("Using ContextCustomizerFactory implementations for test class [%s]: %s"
.formatted(testClass.getSimpleName(), classSimpleNames(factories)));
}
return factories;
}
/**
* Get the {@link ContextCustomizerFactory} instances for this bootstrapper.
* <p>The default implementation delegates to
@@ -407,6 +471,33 @@ public abstract class AbstractTestContextBootstrapper implements TestContextBoot
return TestContextSpringFactoriesUtils.loadFactoryImplementations(ContextCustomizerFactory.class);
}
@SuppressWarnings("unchecked")
private List<ContextCustomizerFactory> instantiateCustomizerFactories(Class<? extends ContextCustomizerFactory>... classes) {
List<ContextCustomizerFactory> factories = new ArrayList<>(classes.length);
for (Class<? extends ContextCustomizerFactory> factoryClass : classes) {
try {
factories.add(BeanUtils.instantiateClass(factoryClass));
}
catch (BeanInstantiationException ex) {
Throwable cause = ex.getCause();
if (cause instanceof ClassNotFoundException || cause instanceof NoClassDefFoundError) {
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(ContextCustomizerFactory.class.getSimpleName(), factoryClass.getName(),
cause.getMessage()));
}
}
else {
throw ex;
}
}
}
return factories;
}
/**
* Resolve the {@link ContextLoader} {@linkplain Class class} to use for the
* supplied list of {@link ContextConfigurationAttributes} and then instantiate