From c5d7908a7876855c9385dcbb3ae24a3f67c42e01 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:35:42 +0200 Subject: [PATCH] Find transitive interface annotations in findAllLocalMergedAnnotations() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit picks up where ce718cf699d 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 --- .../context/TestContextAnnotationUtils.java | 17 ++++++++++------- .../TestContextAnnotationUtilsTests.java | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java b/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java index 2d6ace8f5d8..437b25a6d1f 100644 --- a/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java +++ b/spring-test/src/main/java/org/springframework/test/context/TestContextAnnotationUtils.java @@ -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 findAllLocalMergedAnnotations() { - Class rootDeclaringClass = getRootDeclaringClass(); - Set> 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 annotationType = getAnnotationType(); + Stream> classAnnotations = + MergedAnnotations.from(this.rootDeclaringClass, SearchStrategy.DIRECT, RepeatableContainers.none()) + .stream(annotationType); + Stream> 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()); } diff --git a/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java b/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java index d9f9a2bb067..08f950d96d5 100644 --- a/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java +++ b/spring-test/src/test/java/org/springframework/test/context/TestContextAnnotationUtilsTests.java @@ -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 { }