diff --git a/spring-test/src/main/java/org/springframework/test/context/BootstrapUtils.java b/spring-test/src/main/java/org/springframework/test/context/BootstrapUtils.java index 2113cb0100e..19449b93454 100644 --- a/spring-test/src/main/java/org/springframework/test/context/BootstrapUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/BootstrapUtils.java @@ -19,15 +19,21 @@ package org.springframework.test.context; import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; import org.springframework.beans.BeanUtils; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotationPredicates; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.core.log.LogMessage; -import org.springframework.test.context.TestContextAnnotationUtils.AnnotationDescriptor; import org.springframework.util.ClassUtils; /** @@ -169,25 +175,33 @@ public abstract class BootstrapUtils { } private static @Nullable Class resolveExplicitTestContextBootstrapper(Class testClass) { - Set annotations = new LinkedHashSet<>(); - AnnotationDescriptor descriptor = - TestContextAnnotationUtils.findAnnotationDescriptor(testClass, BootstrapWith.class); - while (descriptor != null) { - annotations.addAll(descriptor.findAllLocalMergedAnnotations()); - descriptor = descriptor.next(); - } - - if (annotations.isEmpty()) { + Map> distanceToAnnotationsMap = MergedAnnotations.search(SearchStrategy.TYPE_HIERARCHY) + .withEnclosingClasses(TestContextAnnotationUtils::searchEnclosingClass) + .from(testClass) + .stream(BootstrapWith.class) + // The following effectively filters out annotations in the type and + // enclosing class hierarchies once annotations have already been found + // on a particular class or interface. + .filter(MergedAnnotationPredicates.firstRunOf(MergedAnnotation::getAggregateIndex)) + // Grouping by "meta-distance" enables us to allow a directly-present + // annotation to override annotations that are meta-present. + // Collecting synthesized annotations for each meta-distance enables + // us to filter out duplicates. + .collect(Collectors.groupingBy(MergedAnnotation::getDistance, TreeMap::new, + Collectors.mapping(MergedAnnotation::synthesize, Collectors.toCollection(LinkedHashSet::new)))); + + if (distanceToAnnotationsMap.isEmpty()) { return null; } - if (annotations.size() == 1) { - return annotations.iterator().next().value(); - } - // Allow directly-present annotation to override annotations that are meta-present. - BootstrapWith bootstrapWith = testClass.getDeclaredAnnotation(BootstrapWith.class); - if (bootstrapWith != null) { - return bootstrapWith.value(); + Set annotations = new LinkedHashSet<>(); + for (Set currentAnnotations: distanceToAnnotationsMap.values()) { + // If we have found a single, non-competing @BootstrapWith annotation, return it. + if (annotations.isEmpty() && currentAnnotations.size() == 1) { + return currentAnnotations.iterator().next().value(); + } + // Otherwise, track all discovered annotations for error reporting. + annotations.addAll(currentAnnotations); } throw new IllegalStateException(String.format( diff --git a/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java b/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java index 6d9077ec6ae..fff6c5f3a4b 100644 --- a/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/BootstrapUtilsTests.java @@ -146,13 +146,7 @@ class BootstrapUtilsTests { */ @Test // gh-35938 void resolveTestContextBootstrapperWithMetaBootstrapWithAnnotationThatOverridesMetaMetaBootstrapWithAnnotation() { - BootstrapContext bootstrapContext = BootstrapTestUtils.buildBootstrapContext( - MetaAndMetaMetaBootstrapWithAnnotationsClass.class, delegate); - assertThatIllegalStateException() - .isThrownBy(() -> resolveTestContextBootstrapper(bootstrapContext)) - .withMessageContaining("Configuration error: found multiple declarations of @BootstrapWith") - .withMessageContaining(FooBootstrapper.class.getSimpleName()) - .withMessageContaining(BarBootstrapper.class.getSimpleName()); + assertBootstrapper(MetaAndMetaMetaBootstrapWithAnnotationsClass.class, BarBootstrapper.class); } /**