From e78471699c20c87b34d795732a1cd98abfe455d2 Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Fri, 26 Jun 2026 12:04:59 +0200 Subject: [PATCH 1/2] Pick up all @TypeExcludeFilters annotations on test classes Previously, TypeExcludeFiltersContextCustomizerFactory only discovered the first @TypeExcludeFilters annotation. This commit updates it to discover all @TypeExcludeFilters annotations instead of stopping at the first. Signed-off-by: Dmytro Nosan See gh-51489 --- ...notationCustomizableTypeExcludeFilter.java | 7 +- ...xcludeFiltersContextCustomizerFactory.java | 26 ++- ...eFiltersContextCustomizerFactoryTests.java | 217 ++++++++++++++++-- 3 files changed, 223 insertions(+), 27 deletions(-) diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/StandardAnnotationCustomizableTypeExcludeFilter.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/StandardAnnotationCustomizableTypeExcludeFilter.java index 3c68d037402..79da575ad36 100644 --- a/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/StandardAnnotationCustomizableTypeExcludeFilter.java +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/StandardAnnotationCustomizableTypeExcludeFilter.java @@ -27,6 +27,7 @@ import org.springframework.core.ResolvableType; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; +import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.util.Assert; /** @@ -55,8 +56,10 @@ public abstract class StandardAnnotationCustomizableTypeExcludeFilter annotation; protected StandardAnnotationCustomizableTypeExcludeFilter(Class testClass) { - this.annotation = MergedAnnotations.from(testClass, SearchStrategy.INHERITED_ANNOTATIONS) - .get(getAnnotationType()); + this.annotation = MergedAnnotations.search(SearchStrategy.TYPE_HIERARCHY) + .withEnclosingClasses(TestContextAnnotationUtils::searchEnclosingClass) + .from(testClass) + .get(this.getAnnotationType()); } protected final MergedAnnotation getAnnotation() { diff --git a/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactory.java b/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactory.java index 04b91385e2e..87817eeeef2 100644 --- a/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactory.java +++ b/core/spring-boot-test/src/main/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactory.java @@ -19,16 +19,20 @@ package org.springframework.boot.test.context.filter.annotation; import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; import org.springframework.aot.AotDetector; import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.test.context.ContextConfigurationAttributes; import org.springframework.test.context.ContextCustomizer; import org.springframework.test.context.ContextCustomizerFactory; import org.springframework.test.context.TestContextAnnotationUtils; -import org.springframework.test.context.TestContextAnnotationUtils.AnnotationDescriptor; /** * {@link ContextCustomizerFactory} to support @@ -45,19 +49,17 @@ class TypeExcludeFiltersContextCustomizerFactory implements ContextCustomizerFac if (AotDetector.useGeneratedArtifacts()) { return null; } - AnnotationDescriptor descriptor = TestContextAnnotationUtils - .findAnnotationDescriptor(testClass, TypeExcludeFilters.class); - if (descriptor == null) { + Set> filterClasses = MergedAnnotations.search(SearchStrategy.TYPE_HIERARCHY) + .withEnclosingClasses(TestContextAnnotationUtils::searchEnclosingClass) + .from(testClass) + .stream(TypeExcludeFilters.class) + .map(MergedAnnotation::synthesize) + .flatMap((annotation) -> Arrays.stream(annotation.value())) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (filterClasses.isEmpty()) { return null; } - Class[] filterClasses = descriptor.getAnnotation().value(); - return createContextCustomizer(descriptor.getRootDeclaringClass(), filterClasses); - } - - @SuppressWarnings("unchecked") - private ContextCustomizer createContextCustomizer(Class testClass, Class[] filterClasses) { - return new TypeExcludeFiltersContextCustomizer(testClass, - new LinkedHashSet<>(Arrays.asList((Class[]) filterClasses))); + return new TypeExcludeFiltersContextCustomizer(testClass, filterClasses); } } diff --git a/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java b/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java index 6bc3411eada..8b6a9fe8d98 100644 --- a/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java +++ b/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java @@ -16,20 +16,26 @@ package org.springframework.boot.test.context.filter.annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.util.Collections; +import org.assertj.core.api.InstanceOfAssertFactories; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.springframework.boot.context.TypeExcludeFilter; -import org.springframework.boot.test.context.filter.annotation.TypeExcludeFiltersContextCustomizerFactoryTests.EnclosingClass.WithEnclosingClassExcludeFilters; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.type.classreading.MetadataReader; import org.springframework.core.type.classreading.MetadataReaderFactory; -import org.springframework.core.type.classreading.SimpleMetadataReaderFactory; import org.springframework.test.context.ContextCustomizer; import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.NestedTestConfiguration; +import org.springframework.test.context.NestedTestConfiguration.EnclosingConfiguration; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -63,11 +69,18 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { @Test void getContextCustomizerWhenEnclosingClassHasAnnotationShouldReturnCustomizer() { - ContextCustomizer customizer = this.factory.createContextCustomizer(WithEnclosingClassExcludeFilters.class, - Collections.emptyList()); + ContextCustomizer customizer = this.factory + .createContextCustomizer(EnclosingClass.WithEnclosingClassExcludeFilters.class, Collections.emptyList()); assertThat(customizer).isNotNull(); } + @Test + void getContextCustomizerWhenEnclosingClassHasAnnotationButNestedConfigurationIsOverrideShouldReturnNull() { + ContextCustomizer customizer = this.factory + .createContextCustomizer(EnclosingWithOverride.InnerWithOverride.class, Collections.emptyList()); + assertThat(customizer).isNull(); + } + @Test void hashCodeAndEquals() { ContextCustomizer customizer1 = this.factory.createContextCustomizer(WithExcludeFilters.class, @@ -82,19 +95,54 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { @Test void getContextCustomizerShouldAddExcludeFilters() throws Exception { - ContextCustomizer customizer = this.factory.createContextCustomizer(WithExcludeFilters.class, + typeExcludeFiltersFor(WithExcludeFilters.class).doesNotMatch(NoAnnotation.class) + .matches(SimpleExclude.class, TestClassAwareExclude.class); + } + + @Test + void getContextCustomizerWhenEnclosingClassHasAnnotationShouldAddExcludeFilters() throws Exception { + typeExcludeFiltersFor(EnclosingClass.WithEnclosingClassExcludeFilters.class).matches(SimpleExclude.class, + TestClassAwareExclude.class); + } + + @Test + void getContextCustomizerWhenHasDuplicateSliceExcludeFilterShouldInstantiateItOnce() { + ContextCustomizer customizer = this.factory.createContextCustomizer(WithDuplicateSliceExclude.class, Collections.emptyList()); + assertThat(customizer).extracting("filters", InstanceOfAssertFactories.collection(TypeExcludeFilter.class)) + .hasSize(1); + } + + @Test + void getContextCustomizerWhenEnclosingClassHasAnnotationsTypeExcludeFilters() throws Exception { + typeExcludeFiltersFor(WithMultipleExcludeFilterAnnotations.WithEnclosingClassExcludeFilters.class) + .matches(FirstSliceExclude.class, SecondSliceExclude.class); + + } + + @Test + void getContextCustomizerWhenSuperclassHasAnnotationShouldIncludeTypeExcludeFilters() throws Exception { + typeExcludeFiltersFor(WithMixedInheritance.class).matches(TestClassAwareExclude.class, FirstSliceExclude.class, + SecondSliceExclude.class, ThirdSliceExclude.class); + } + + @Test + void getContextCustomizerWhenHasNestedComposedAnnotationShouldIncludeTypeExcludeFilters() throws Exception { + typeExcludeFiltersFor(WithComposedAnnotation.class).matches(FirstSliceExclude.class); + } + + @Test + void getContextCustomizerWhenDeeplyNestedShouldIncludeAllEnclosingExcludeFilters() throws Exception { + typeExcludeFiltersFor(GrandparentEnclosing.ParentEnclosing.DeepInnerClass.class) + .matches(FirstSliceExclude.class, SecondSliceExclude.class, ThirdSliceExclude.class); + } + + private TypeExcludeFilterAssert typeExcludeFiltersFor(Class testClass) { + ContextCustomizer customizer = this.factory.createContextCustomizer(testClass, Collections.emptyList()); assertThat(customizer).isNotNull(); customizer.customizeContext(this.context, this.mergedContextConfiguration); this.context.refresh(); - TypeExcludeFilter filter = this.context.getBean(TypeExcludeFilter.class); - MetadataReaderFactory metadataReaderFactory = new SimpleMetadataReaderFactory(); - MetadataReader metadataReader = metadataReaderFactory.getMetadataReader(NoAnnotation.class.getName()); - assertThat(filter.match(metadataReader, metadataReaderFactory)).isFalse(); - metadataReader = metadataReaderFactory.getMetadataReader(SimpleExclude.class.getName()); - assertThat(filter.match(metadataReader, metadataReaderFactory)).isTrue(); - metadataReader = metadataReaderFactory.getMetadataReader(TestClassAwareExclude.class.getName()); - assertThat(filter.match(metadataReader, metadataReaderFactory)).isTrue(); + return new TypeExcludeFilterAssert(this.context.getBean(TypeExcludeFilter.class)); } static class NoAnnotation { @@ -152,4 +200,147 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { } + @FirstTestSlice + @TypeExcludeFilters(SecondSliceExclude.class) + static class WithMultipleExcludeFilterAnnotations { + + class WithEnclosingClassExcludeFilters { + + } + + } + + @TypeExcludeFilters(TestClassAwareExclude.class) + static class WithMixedInheritance extends WithFirstTestSliceExclude { + + } + + @FirstTestSlice + static class WithFirstTestSliceExclude implements WithSecondTestSliceExclude { + + } + + @SecondTestSlice + interface WithSecondTestSliceExclude extends WithThirdTestSliceExclude { + + } + + @TypeExcludeFilters(ThirdSliceExclude.class) + interface WithThirdTestSliceExclude { + + } + + @ComposedFirstTestSlice + static class WithComposedAnnotation { + + } + + @FirstTestSlice + @TypeExcludeFilters(FirstSliceExclude.class) + static class WithDuplicateSliceExclude { + + } + + @Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @TypeExcludeFilters(FirstSliceExclude.class) + @interface FirstTestSlice { + + } + + @Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @TypeExcludeFilters(SecondSliceExclude.class) + @interface SecondTestSlice { + + } + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @FirstTestSlice + @interface ComposedFirstTestSlice { + + } + + @TypeExcludeFilters(FirstSliceExclude.class) + static class EnclosingWithOverride { + + @NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) + class InnerWithOverride { + + } + + } + + @TypeExcludeFilters(FirstSliceExclude.class) + static class GrandparentEnclosing { + + @TypeExcludeFilters(SecondSliceExclude.class) + class ParentEnclosing { + + @TypeExcludeFilters(ThirdSliceExclude.class) + class DeepInnerClass { + + } + + } + + } + + static class FirstSliceExclude extends TestClassAwareExclude { + + FirstSliceExclude(Class testClass) { + super(testClass); + } + + } + + static class SecondSliceExclude extends TestClassAwareExclude { + + SecondSliceExclude(Class testClass) { + super(testClass); + } + + } + + static class ThirdSliceExclude extends TestClassAwareExclude { + + ThirdSliceExclude(Class testClass) { + super(testClass); + } + + } + + private static final class TypeExcludeFilterAssert { + + private final TypeExcludeFilter filter; + + private final MetadataReaderFactory metadataReaderFactory = MetadataReaderFactory + .create(new DefaultResourceLoader()); + + private TypeExcludeFilterAssert(TypeExcludeFilter filter) { + this.filter = filter; + } + + TypeExcludeFilterAssert matches(Class... types) throws Exception { + for (Class type : types) { + assertThat(matches(type)).as("Filter should match %s", type.getName()).isTrue(); + } + return this; + } + + TypeExcludeFilterAssert doesNotMatch(Class... types) throws Exception { + for (Class type : types) { + assertThat(matches(type)).as("Filter should not match %s", type.getName()).isFalse(); + } + return this; + } + + private boolean matches(Class type) throws Exception { + MetadataReader metadataReader = this.metadataReaderFactory.getMetadataReader(type.getName()); + return this.filter.match(metadataReader, this.metadataReaderFactory); + } + + } + } From fafa1c437559d785a69cb5d0ccb0e5e52adae237 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 17 Sep 2026 12:04:46 +0100 Subject: [PATCH 2/2] Polish "Pick up all @TypeExcludeFilters annotations on test classes" See gh-51489 Signed-off-by: Andy Wilkinson --- ...eFiltersContextCustomizerFactoryTests.java | 59 ++++++++----------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java b/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java index 8b6a9fe8d98..0afaca18287 100644 --- a/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java +++ b/core/spring-boot-test/src/test/java/org/springframework/boot/test/context/filter/annotation/TypeExcludeFiltersContextCustomizerFactoryTests.java @@ -116,25 +116,25 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { @Test void getContextCustomizerWhenEnclosingClassHasAnnotationsTypeExcludeFilters() throws Exception { typeExcludeFiltersFor(WithMultipleExcludeFilterAnnotations.WithEnclosingClassExcludeFilters.class) - .matches(FirstSliceExclude.class, SecondSliceExclude.class); + .matches(SliceExclude.class, SecondExclude.class); } @Test void getContextCustomizerWhenSuperclassHasAnnotationShouldIncludeTypeExcludeFilters() throws Exception { - typeExcludeFiltersFor(WithMixedInheritance.class).matches(TestClassAwareExclude.class, FirstSliceExclude.class, - SecondSliceExclude.class, ThirdSliceExclude.class); + typeExcludeFiltersFor(WithMixedInheritance.class).matches(TestClassAwareExclude.class, SliceExclude.class, + SecondExclude.class, ThirdExclude.class); } @Test void getContextCustomizerWhenHasNestedComposedAnnotationShouldIncludeTypeExcludeFilters() throws Exception { - typeExcludeFiltersFor(WithComposedAnnotation.class).matches(FirstSliceExclude.class); + typeExcludeFiltersFor(WithComposedAnnotation.class).matches(SliceExclude.class); } @Test void getContextCustomizerWhenDeeplyNestedShouldIncludeAllEnclosingExcludeFilters() throws Exception { - typeExcludeFiltersFor(GrandparentEnclosing.ParentEnclosing.DeepInnerClass.class) - .matches(FirstSliceExclude.class, SecondSliceExclude.class, ThirdSliceExclude.class); + typeExcludeFiltersFor(GrandparentEnclosing.ParentEnclosing.DeepInnerClass.class).matches(SliceExclude.class, + SecondExclude.class, ThirdExclude.class); } private TypeExcludeFilterAssert typeExcludeFiltersFor(Class testClass) { @@ -200,8 +200,8 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { } - @FirstTestSlice - @TypeExcludeFilters(SecondSliceExclude.class) + @TestSlice + @TypeExcludeFilters(SecondExclude.class) static class WithMultipleExcludeFilterAnnotations { class WithEnclosingClassExcludeFilters { @@ -215,17 +215,17 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { } - @FirstTestSlice + @TestSlice static class WithFirstTestSliceExclude implements WithSecondTestSliceExclude { } - @SecondTestSlice + @TypeExcludeFilters(SecondExclude.class) interface WithSecondTestSliceExclude extends WithThirdTestSliceExclude { } - @TypeExcludeFilters(ThirdSliceExclude.class) + @TypeExcludeFilters(ThirdExclude.class) interface WithThirdTestSliceExclude { } @@ -235,34 +235,27 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { } - @FirstTestSlice - @TypeExcludeFilters(FirstSliceExclude.class) + @TestSlice + @TypeExcludeFilters(SliceExclude.class) static class WithDuplicateSliceExclude { } @Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) - @TypeExcludeFilters(FirstSliceExclude.class) - @interface FirstTestSlice { - - } - - @Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE }) - @Retention(RetentionPolicy.RUNTIME) - @TypeExcludeFilters(SecondSliceExclude.class) - @interface SecondTestSlice { + @TypeExcludeFilters(SliceExclude.class) + @interface TestSlice { } @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) - @FirstTestSlice + @TestSlice @interface ComposedFirstTestSlice { } - @TypeExcludeFilters(FirstSliceExclude.class) + @TypeExcludeFilters(SliceExclude.class) static class EnclosingWithOverride { @NestedTestConfiguration(EnclosingConfiguration.OVERRIDE) @@ -272,13 +265,13 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { } - @TypeExcludeFilters(FirstSliceExclude.class) + @TypeExcludeFilters(SliceExclude.class) static class GrandparentEnclosing { - @TypeExcludeFilters(SecondSliceExclude.class) + @TypeExcludeFilters(SecondExclude.class) class ParentEnclosing { - @TypeExcludeFilters(ThirdSliceExclude.class) + @TypeExcludeFilters(ThirdExclude.class) class DeepInnerClass { } @@ -287,25 +280,25 @@ class TypeExcludeFiltersContextCustomizerFactoryTests { } - static class FirstSliceExclude extends TestClassAwareExclude { + static class SliceExclude extends TestClassAwareExclude { - FirstSliceExclude(Class testClass) { + SliceExclude(Class testClass) { super(testClass); } } - static class SecondSliceExclude extends TestClassAwareExclude { + static class SecondExclude extends TestClassAwareExclude { - SecondSliceExclude(Class testClass) { + SecondExclude(Class testClass) { super(testClass); } } - static class ThirdSliceExclude extends TestClassAwareExclude { + static class ThirdExclude extends TestClassAwareExclude { - ThirdSliceExclude(Class testClass) { + ThirdExclude(Class testClass) { super(testClass); }