Merge pull request #51489 from nosan

Closes gh-51489

* gh-51489:
  Polish "Pick up all @TypeExcludeFilters annotations on test classes"
  Pick up all @TypeExcludeFilters annotations on test classes
This commit is contained in:
Andy Wilkinson
2026-09-17 12:21:59 +01:00
3 changed files with 216 additions and 27 deletions
@@ -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<A extends
private final MergedAnnotation<A> 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<A> getAnnotation() {
@@ -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<TypeExcludeFilters> descriptor = TestContextAnnotationUtils
.findAnnotationDescriptor(testClass, TypeExcludeFilters.class);
if (descriptor == null) {
Set<Class<? extends TypeExcludeFilter>> 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<? extends TypeExcludeFilter>[]) filterClasses)));
return new TypeExcludeFiltersContextCustomizer(testClass, filterClasses);
}
}
@@ -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(SliceExclude.class, SecondExclude.class);
}
@Test
void getContextCustomizerWhenSuperclassHasAnnotationShouldIncludeTypeExcludeFilters() throws Exception {
typeExcludeFiltersFor(WithMixedInheritance.class).matches(TestClassAwareExclude.class, SliceExclude.class,
SecondExclude.class, ThirdExclude.class);
}
@Test
void getContextCustomizerWhenHasNestedComposedAnnotationShouldIncludeTypeExcludeFilters() throws Exception {
typeExcludeFiltersFor(WithComposedAnnotation.class).matches(SliceExclude.class);
}
@Test
void getContextCustomizerWhenDeeplyNestedShouldIncludeAllEnclosingExcludeFilters() throws Exception {
typeExcludeFiltersFor(GrandparentEnclosing.ParentEnclosing.DeepInnerClass.class).matches(SliceExclude.class,
SecondExclude.class, ThirdExclude.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,140 @@ class TypeExcludeFiltersContextCustomizerFactoryTests {
}
@TestSlice
@TypeExcludeFilters(SecondExclude.class)
static class WithMultipleExcludeFilterAnnotations {
class WithEnclosingClassExcludeFilters {
}
}
@TypeExcludeFilters(TestClassAwareExclude.class)
static class WithMixedInheritance extends WithFirstTestSliceExclude {
}
@TestSlice
static class WithFirstTestSliceExclude implements WithSecondTestSliceExclude {
}
@TypeExcludeFilters(SecondExclude.class)
interface WithSecondTestSliceExclude extends WithThirdTestSliceExclude {
}
@TypeExcludeFilters(ThirdExclude.class)
interface WithThirdTestSliceExclude {
}
@ComposedFirstTestSlice
static class WithComposedAnnotation {
}
@TestSlice
@TypeExcludeFilters(SliceExclude.class)
static class WithDuplicateSliceExclude {
}
@Target({ ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@TypeExcludeFilters(SliceExclude.class)
@interface TestSlice {
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@TestSlice
@interface ComposedFirstTestSlice {
}
@TypeExcludeFilters(SliceExclude.class)
static class EnclosingWithOverride {
@NestedTestConfiguration(EnclosingConfiguration.OVERRIDE)
class InnerWithOverride {
}
}
@TypeExcludeFilters(SliceExclude.class)
static class GrandparentEnclosing {
@TypeExcludeFilters(SecondExclude.class)
class ParentEnclosing {
@TypeExcludeFilters(ThirdExclude.class)
class DeepInnerClass {
}
}
}
static class SliceExclude extends TestClassAwareExclude {
SliceExclude(Class<?> testClass) {
super(testClass);
}
}
static class SecondExclude extends TestClassAwareExclude {
SecondExclude(Class<?> testClass) {
super(testClass);
}
}
static class ThirdExclude extends TestClassAwareExclude {
ThirdExclude(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);
}
}
}