Find transitive interface annotations in findAllLocalMergedAnnotations()

This commit picks up where ce718cf699 left off by ensuring that
findAllLocalMergedAnnotations() also finds annotations declared on
transitive interfaces — that is, on interfaces of the directly
implemented interfaces of the root declaring class.

The previous implementation filtered annotations from a single
TYPE_HIERARCHY search by checking whether the annotation's source was
either the root declaring class or one of its directly declared
interfaces. However, this excluded annotations inherited through an
interface chain such as First -> Second -> Third, where the annotation
is declared on Third but not on Second.

This commit replaces that approach with two targeted searches whose
results are combined:

- DIRECT on the root declaring class, to capture annotations declared
  directly on the class (including via composed/meta-annotations)
- TYPE_HIERARCHY on each directly implemented interface, which
  naturally traverses the full super-interface chain of each interface

A corresponding test for this scenario has also been added.

Closes gh-36975
This commit is contained in:
Sam Brannen
2026-06-30 16:44:53 +02:00
parent ce718cf699
commit c5d7908a78
2 changed files with 28 additions and 7 deletions
@@ -17,10 +17,12 @@
package org.springframework.test.context;
import java.lang.annotation.Annotation;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
import org.jspecify.annotations.Nullable;
@@ -578,13 +580,14 @@ public abstract class TestContextAnnotationUtils {
* or an empty set if none were found
*/
public Set<T> findAllLocalMergedAnnotations() {
Class<?> rootDeclaringClass = getRootDeclaringClass();
Set<Class<?>> localSources = new HashSet<>();
localSources.add(rootDeclaringClass);
Collections.addAll(localSources, rootDeclaringClass.getInterfaces());
return MergedAnnotations.from(rootDeclaringClass, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none())
.stream(getAnnotationType())
.filter(mergedAnnotation -> localSources.contains(mergedAnnotation.getSource()))
Class<T> annotationType = getAnnotationType();
Stream<MergedAnnotation<T>> classAnnotations =
MergedAnnotations.from(this.rootDeclaringClass, SearchStrategy.DIRECT, RepeatableContainers.none())
.stream(annotationType);
Stream<MergedAnnotation<T>> interfaceAnnotations = Arrays.stream(this.rootDeclaringClass.getInterfaces())
.flatMap(ifc -> MergedAnnotations.from(ifc, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none())
.stream(annotationType));
return Stream.concat(classAnnotations, interfaceAnnotations)
.collect(MergedAnnotationCollectors.toAnnotationSet());
}
@@ -296,6 +296,17 @@ class TestContextAnnotationUtilsTests {
.satisfies(config -> assertThat(config.classes()).containsExactly(Config2.class));
}
@Test
void annotationOnTransitiveInterface() {
var descriptor = findAnnotationDescriptor(ClassImplementingTransitiveAnnotatedInterface.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.findAllLocalMergedAnnotations())
.satisfiesExactly(
config1 -> assertThat(config1.classes()).containsExactly(Config1.class),
config2 -> assertThat(config2.classes()).containsExactly(Config2.class)
);
}
@Test
void annotationOnClassAndInterface() {
var descriptor = findAnnotationDescriptor(AnnotatedClassImplementingAnnotatedInterface.class, ContextConfiguration.class);
@@ -765,6 +776,13 @@ class TestContextAnnotationUtilsTests {
static class ClassImplementingAnnotatedInterface implements AnnotatedInterface {
}
interface TransitiveAnnotatedInterface extends AnnotatedInterface {
}
@ContextConfiguration(classes = Config1.class)
static class ClassImplementingTransitiveAnnotatedInterface implements TransitiveAnnotatedInterface {
}
@ContextConfiguration(classes = Config1.class)
static class AnnotatedClassImplementingAnnotatedInterface implements AnnotatedInterface {
}