diff --git a/archunit-junit/junit4/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerInternal.java b/archunit-junit/junit4/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerInternal.java index 2fd97c2419..7d2e51bf9e 100644 --- a/archunit-junit/junit4/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerInternal.java +++ b/archunit-junit/junit4/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerInternal.java @@ -63,11 +63,14 @@ final class ArchUnitRunnerInternal extends ParentRunner imple } private static AnalyzeClasses checkAnnotation(Class testClass) { - AnalyzeClasses analyzeClasses = testClass.getAnnotation(AnalyzeClasses.class); - ArchTestInitializationException.check(analyzeClasses != null, + List analyzeClasses = new AnnotationFinder<>(AnalyzeClasses.class).findAnnotationsOn(testClass); + ArchTestInitializationException.check(!analyzeClasses.isEmpty(), "Class %s must be annotated with @%s", testClass.getSimpleName(), AnalyzeClasses.class.getSimpleName()); - return analyzeClasses; + ArchTestInitializationException.check(analyzeClasses.size() == 1, + "Multiple @%s annotations found on %s! This is not supported at the moment.", + AnalyzeClasses.class.getSimpleName(), testClass.getSimpleName()); + return analyzeClasses.get(0); } @Override diff --git a/archunit-junit/junit4/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerTest.java b/archunit-junit/junit4/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerTest.java index d1e1d1c13c..4cfe81d22a 100644 --- a/archunit-junit/junit4/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerTest.java +++ b/archunit-junit/junit4/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitRunnerTest.java @@ -1,5 +1,7 @@ package com.tngtech.archunit.junit.internal; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Set; import com.tngtech.archunit.core.domain.JavaClass; @@ -50,6 +52,8 @@ public class ArchUnitRunnerTest { private ArchUnitRunnerInternal runner = newRunner(SomeArchTest.class); @InjectMocks private ArchUnitRunnerInternal runnerOfMaxTest = newRunner(MaxAnnotatedTest.class); + @InjectMocks + private ArchUnitRunnerInternal runnerOfMetaAnnotatedAnalyzerClasses = newRunner(MetaAnnotatedTest.class); @Before public void setUp() { @@ -96,6 +100,35 @@ public void rejects_missing_analyze_annotation() { .hasMessageContaining(AnalyzeClasses.class.getSimpleName()); } + @Test + public void runner_creates_correct_analysis_request_for_meta_annotated_class() { + runnerOfMetaAnnotatedAnalyzerClasses.run(new RunNotifier()); + + verify(cache).getClassesToAnalyzeFor(eq(MetaAnnotatedTest.class), analysisRequestCaptor.capture()); + + AnalyzeClasses analyzeClasses = MetaAnnotatedTest.class.getAnnotation(MetaAnnotatedTest.MetaAnalyzeCls.class) + .annotationType().getAnnotation(AnalyzeClasses.class); + ClassAnalysisRequest analysisRequest = analysisRequestCaptor.getValue(); + assertThat(analysisRequest.getPackageNames()).isEqualTo(analyzeClasses.packages()); + assertThat(analysisRequest.getPackageRoots()).isEqualTo(analyzeClasses.packagesOf()); + assertThat(analysisRequest.getLocationProviders()).isEqualTo(analyzeClasses.locations()); + assertThat(analysisRequest.scanWholeClasspath()).as("scan whole classpath").isTrue(); + assertThat(analysisRequest.getImportOptions()).isEqualTo(analyzeClasses.importOptions()); + } + + @Test + public void rejects_if_multiple_analyze_annotations() { + assertThatThrownBy( + () -> new ArchUnitRunnerInternal(MultipleAnalyzeClzAnnotationsTest.class) + ) + .isInstanceOf(ArchTestInitializationException.class) + .hasMessageContaining("Multiple") + .hasMessageContaining(AnalyzeClasses.class.getSimpleName()) + .hasMessageContaining("found") + .hasMessageContaining(MultipleAnalyzeClzAnnotationsTest.class.getSimpleName()) + .hasMessageContaining("not supported"); + } + private ArchUnitRunnerInternal newRunner(Class testClass) { try { return new ArchUnitRunnerInternal(testClass); @@ -160,4 +193,25 @@ public static class MaxAnnotatedTest { public static void someTest(JavaClasses classes) { } } + + @MetaAnnotatedTest.MetaAnalyzeCls + public static class MetaAnnotatedTest { + @ArchTest + public static void someTest(JavaClasses classes) { + } + + @Retention(RetentionPolicy.RUNTIME) + @AnalyzeClasses( + packages = {"com.forty", "com.two"}, + wholeClasspath = true + ) + public @interface MetaAnalyzeCls { + } + } + + @MetaAnnotatedTest.MetaAnalyzeCls + @AnalyzeClasses + public static class MultipleAnalyzeClzAnnotationsTest { + + } } diff --git a/archunit-junit/junit5/engine/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitTestDescriptor.java b/archunit-junit/junit5/engine/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitTestDescriptor.java index d66a449627..fb783c228f 100644 --- a/archunit-junit/junit5/engine/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitTestDescriptor.java +++ b/archunit-junit/junit5/engine/src/main/java/com/tngtech/archunit/junit/internal/ArchUnitTestDescriptor.java @@ -19,6 +19,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Member; import java.lang.reflect.Method; +import java.util.List; import java.util.function.Consumer; import java.util.function.Supplier; @@ -39,7 +40,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static com.google.common.base.Preconditions.checkArgument; import static com.tngtech.archunit.junit.internal.DisplayNameResolver.determineDisplayName; import static com.tngtech.archunit.junit.internal.ReflectionUtils.getAllFields; import static com.tngtech.archunit.junit.internal.ReflectionUtils.getAllMethods; @@ -71,7 +71,8 @@ static void resolve(TestDescriptor parent, ElementResolver resolver, ClassCache } private static void createTestDescriptor(TestDescriptor parent, ClassCache classCache, Class clazz, ElementResolver childResolver) { - if (clazz.getAnnotation(AnalyzeClasses.class) == null) { + List analyzeClasses = new AnnotationFinder<>(AnalyzeClasses.class).findAnnotationsOn(clazz); + if (analyzeClasses.isEmpty()) { LOG.warn("Class {} is not annotated with @{} and thus cannot run as a top level test. " + "This warning can be ignored if {} is only used as part of a rules library included via {}.in({}.class).", clazz.getName(), AnalyzeClasses.class.getSimpleName(), @@ -80,6 +81,10 @@ private static void createTestDescriptor(TestDescriptor parent, ClassCache class return; } + ArchTestInitializationException.check(analyzeClasses.size() == 1, + "Multiple @%s annotations found on %s! This is not supported at the moment.", + AnalyzeClasses.class.getSimpleName(), clazz.getSimpleName()); + ArchUnitTestDescriptor classDescriptor = new ArchUnitTestDescriptor(childResolver, clazz, classCache); parent.addChild(classDescriptor); classDescriptor.createChildren(childResolver); @@ -295,11 +300,14 @@ private static class JUnit5ClassAnalysisRequest implements ClassAnalysisRequest } private static AnalyzeClasses checkAnnotation(Class testClass) { - AnalyzeClasses analyzeClasses = testClass.getAnnotation(AnalyzeClasses.class); - checkArgument(analyzeClasses != null, + List analyzeClasses = new AnnotationFinder<>(AnalyzeClasses.class).findAnnotationsOn(testClass); + ArchTestInitializationException.check(!analyzeClasses.isEmpty(), "Class %s must be annotated with @%s", testClass.getSimpleName(), AnalyzeClasses.class.getSimpleName()); - return analyzeClasses; + ArchTestInitializationException.check(analyzeClasses.size() == 1, + "Multiple @%s annotations found on %s! This is not supported at the moment.", + AnalyzeClasses.class.getSimpleName(), testClass.getSimpleName()); + return analyzeClasses.get(0); } @Override diff --git a/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitTestEngineTest.java b/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitTestEngineTest.java index 1fac69668c..de600340fb 100644 --- a/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitTestEngineTest.java +++ b/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/ArchUnitTestEngineTest.java @@ -27,6 +27,7 @@ import com.tngtech.archunit.junit.internal.testexamples.FullAnalyzeClassesSpec; import com.tngtech.archunit.junit.internal.testexamples.LibraryWithPrivateTests; import com.tngtech.archunit.junit.internal.testexamples.SimpleRuleLibrary; +import com.tngtech.archunit.junit.internal.testexamples.TestClassWithMetaAnnotationForAnalyzeClasses; import com.tngtech.archunit.junit.internal.testexamples.TestClassWithMetaTag; import com.tngtech.archunit.junit.internal.testexamples.TestClassWithMetaTags; import com.tngtech.archunit.junit.internal.testexamples.TestClassWithTags; @@ -53,6 +54,7 @@ import com.tngtech.archunit.junit.internal.testexamples.subtwo.SimpleRules; import com.tngtech.archunit.junit.internal.testexamples.wrong.WrongRuleMethodNotStatic; import com.tngtech.archunit.junit.internal.testexamples.wrong.WrongRuleMethodWrongParameters; +import com.tngtech.archunit.junit.internal.testexamples.wrong.WrongTestClassWithMultipleAnalyzeClassesAnnotations; import com.tngtech.archunit.junit.internal.testutil.LogCaptor; import com.tngtech.archunit.junit.internal.testutil.SystemPropertiesExtension; import com.tngtech.archunit.junit.internal.testutil.TestLogExtension; @@ -169,6 +171,20 @@ void a_single_test_class() { assertThat(child.getParent().get()).isEqualTo(descriptor); } + @Test + void a_test_class_with_meta_annotation_for_analyze_classes() { + EngineDiscoveryTestRequest discoveryRequest = new EngineDiscoveryTestRequest().withClass(TestClassWithMetaAnnotationForAnalyzeClasses.class); + + TestDescriptor descriptor = testEngine.discover(discoveryRequest, engineId); + + TestDescriptor child = getOnlyElement(descriptor.getChildren()); + assertThat(child).isInstanceOf(ArchUnitTestDescriptor.class); + assertThat(child.getUniqueId()).isEqualTo(engineId.append(CLASS_SEGMENT_TYPE, TestClassWithMetaAnnotationForAnalyzeClasses.class.getName())); + assertThat(child.getDisplayName()).isEqualTo(TestClassWithMetaAnnotationForAnalyzeClasses.class.getSimpleName()); + assertThat(child.getType()).isEqualTo(CONTAINER); + assertThat(child.getParent()).get().isEqualTo(descriptor); + } + @Test void source_of_a_single_test_class() { EngineDiscoveryTestRequest discoveryRequest = new EngineDiscoveryTestRequest().withClass(SimpleRuleField.class); @@ -505,10 +521,10 @@ void mixed_class_methods_and_fields() { expectedLeafIds.add(simpleRuleFieldTestId(engineId)); expectedLeafIds.add(simpleRuleMethodTestId(engineId)); Stream.concat( - SimpleRules.RULE_FIELD_NAMES.stream().map(fieldName -> - simpleRulesId(engineId).append(FIELD_SEGMENT_TYPE, fieldName)), - SimpleRules.RULE_METHOD_NAMES.stream().map(methodName -> - simpleRulesId(engineId).append(METHOD_SEGMENT_TYPE, methodName))) + SimpleRules.RULE_FIELD_NAMES.stream().map(fieldName -> + simpleRulesId(engineId).append(FIELD_SEGMENT_TYPE, fieldName)), + SimpleRules.RULE_METHOD_NAMES.stream().map(methodName -> + simpleRulesId(engineId).append(METHOD_SEGMENT_TYPE, methodName))) .forEach(expectedLeafIds::add); assertThat(getAllLeafUniqueIds(rootDescriptor)) @@ -1074,6 +1090,21 @@ void cache_is_cleared_afterwards() { verify(classCache, atLeastOnce()).getClassesToAnalyzeFor(any(Class.class), any(ClassAnalysisRequest.class)); verifyNoMoreInteractions(classCache); } + + @Test + void a_class_with_meta_annotation_for_analyze_classes() { + execute(createEngineId(), TestClassWithMetaAnnotationForAnalyzeClasses.class); + + verify(classCache).getClassesToAnalyzeFor(eq(TestClassWithMetaAnnotationForAnalyzeClasses.class), classAnalysisRequestCaptor.capture()); + ClassAnalysisRequest request = classAnalysisRequestCaptor.getValue(); + AnalyzeClasses expected = TestClassWithMetaAnnotationForAnalyzeClasses.class.getAnnotation(TestClassWithMetaAnnotationForAnalyzeClasses.MetaAnalyzeCls.class) + .annotationType().getAnnotation(AnalyzeClasses.class); + assertThat(request.getPackageNames()).isEqualTo(expected.packages()); + assertThat(request.getPackageRoots()).isEqualTo(expected.packagesOf()); + assertThat(request.getLocationProviders()).isEqualTo(expected.locations()); + assertThat(request.scanWholeClasspath()).as("scan whole classpath").isTrue(); + assertThat(request.getImportOptions()).isEqualTo(expected.importOptions()); + } } @Nested @@ -1089,6 +1120,19 @@ void rule_method_with_wrong_parameters() { .hasMessageContaining(WrongRuleMethodWrongParameters.WRONG_PARAMETERS_METHOD_NAME) .hasMessageContaining("must have exactly one parameter of type " + JavaClasses.class.getName()); } + + @Test + void a_test_class_with_multiple_analyze_classes_annotations() { + EngineDiscoveryTestRequest discoveryRequest = new EngineDiscoveryTestRequest().withClass(WrongTestClassWithMultipleAnalyzeClassesAnnotations.class); + + assertThatThrownBy(() -> testEngine.discover(discoveryRequest, engineId)) + .isInstanceOf(ArchTestInitializationException.class) + .hasMessageContaining("Multiple") + .hasMessageContaining(AnalyzeClasses.class.getSimpleName()) + .hasMessageContaining("found") + .hasMessageContaining(WrongTestClassWithMultipleAnalyzeClassesAnnotations.class.getSimpleName()) + .hasMessageContaining("not supported"); + } } @Nested diff --git a/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/testexamples/TestClassWithMetaAnnotationForAnalyzeClasses.java b/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/testexamples/TestClassWithMetaAnnotationForAnalyzeClasses.java new file mode 100644 index 0000000000..396bbdb1ff --- /dev/null +++ b/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/testexamples/TestClassWithMetaAnnotationForAnalyzeClasses.java @@ -0,0 +1,24 @@ +package com.tngtech.archunit.junit.internal.testexamples; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@TestClassWithMetaAnnotationForAnalyzeClasses.MetaAnalyzeCls +public class TestClassWithMetaAnnotationForAnalyzeClasses { + + @ArchTest + public static final ArchRule rule_in_class_with_meta_analyze_class_annotation = RuleThatFails.on(UnwantedClass.class); + + @Retention(RUNTIME) + @Target(TYPE) + @AnalyzeClasses(wholeClasspath = true) + public @interface MetaAnalyzeCls { + } +} diff --git a/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/testexamples/wrong/WrongTestClassWithMultipleAnalyzeClassesAnnotations.java b/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/testexamples/wrong/WrongTestClassWithMultipleAnalyzeClassesAnnotations.java new file mode 100644 index 0000000000..812e42b84b --- /dev/null +++ b/archunit-junit/junit5/engine/src/test/java/com/tngtech/archunit/junit/internal/testexamples/wrong/WrongTestClassWithMultipleAnalyzeClassesAnnotations.java @@ -0,0 +1,27 @@ +package com.tngtech.archunit.junit.internal.testexamples.wrong; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.internal.testexamples.RuleThatFails; +import com.tngtech.archunit.junit.internal.testexamples.UnwantedClass; +import com.tngtech.archunit.lang.ArchRule; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@AnalyzeClasses(packages = "dummy") +@WrongTestClassWithMultipleAnalyzeClassesAnnotations.MetaAnalyzeCls +public class WrongTestClassWithMultipleAnalyzeClassesAnnotations { + + @ArchTest + public static final ArchRule dummy_rule = RuleThatFails.on(UnwantedClass.class); + + @Retention(RUNTIME) + @Target(TYPE) + @AnalyzeClasses(wholeClasspath = true) + public @interface MetaAnalyzeCls { + } +} diff --git a/archunit-junit/src/main/java/com/tngtech/archunit/junit/internal/AnnotationFinder.java b/archunit-junit/src/main/java/com/tngtech/archunit/junit/internal/AnnotationFinder.java new file mode 100644 index 0000000000..1be4b39a07 --- /dev/null +++ b/archunit-junit/src/main/java/com/tngtech/archunit/junit/internal/AnnotationFinder.java @@ -0,0 +1,42 @@ +package com.tngtech.archunit.junit.internal; + +import java.lang.annotation.Annotation; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; + +class AnnotationFinder { + + private final Class annotationClass; + + public AnnotationFinder(final Class annotationClass) { + this.annotationClass = annotationClass; + } + + /** + * Recursively retrieve all {@link T} annotations from a given element. + * + * @param clazz The clazz from which to retrieve the annotation. + * @return List of all found annotation instance or empty list. + */ + public List findAnnotationsOn(final Class clazz) { + return findAnnotations(clazz.getAnnotations(), new HashSet<>()); + } + + private List findAnnotations(final Annotation[] annotations, final HashSet visited) { + final List result = new LinkedList<>(); + for (Annotation annotation : annotations) { + if (visited.contains(annotation)) { + continue; + } else { + visited.add(annotation); + } + if (annotationClass.isInstance(annotation)) { + result.add(annotationClass.cast(annotation)); + } else { + result.addAll(findAnnotations(annotation.annotationType().getAnnotations(), visited)); + } + } + return result; + } +} diff --git a/archunit-junit/src/test/java/com/tngtech/archunit/junit/internal/AnnotationFinderTest.java b/archunit-junit/src/test/java/com/tngtech/archunit/junit/internal/AnnotationFinderTest.java new file mode 100644 index 0000000000..432d8b91c4 --- /dev/null +++ b/archunit-junit/src/test/java/com/tngtech/archunit/junit/internal/AnnotationFinderTest.java @@ -0,0 +1,134 @@ +package com.tngtech.archunit.junit.internal; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AnnotationFinderTest { + + private static final Consumer DEFAULT_META = a -> assertThat(a.value()).as("Default annotation").isEqualTo(""); + private static final Consumer META_ONE = a -> assertThat(a.value()).as("Meta one").isEqualTo("One"); + private static final Consumer META_TWO = a -> assertThat(a.value()).as("Meta Two").isEqualTo("Two"); + + private final AnnotationFinder sut = createFinder(); + + @Test + public void should_only_retrieve_direct_annotation() { + // when + List actual = sut.findAnnotationsOn(DirectAnnotated.class); + + // then + assertThat(actual) + .singleElement() + .isInstanceOf(MetaTestAnno.class) + .satisfies(DEFAULT_META); + } + + @Test + public void should_only_retrieve_extended_one_annotation() { + // when + final List actual = sut.findAnnotationsOn(ExtendedAnnotatedOne.class); + + // then + assertThat(actual) + .singleElement() + .isInstanceOf(MetaTestAnno.class) + .satisfies(META_ONE); + } + + @Test + public void should_retrieve_direct_and_extended_one_annotations() { + // when + final List actual = sut.findAnnotationsOn(DirectAndExtendedAnnotatedOne.class); + + // then + assertThat(actual) + .hasSize(2) + .satisfiesExactlyInAnyOrder( + DEFAULT_META, + META_ONE + ); + } + + @Test + public void should_retrieve_all_annotations() { + // when + final List actual = sut.findAnnotationsOn(AllIn.class); + + // then + assertThat(actual) + .hasSize(3) + .satisfiesExactlyInAnyOrder( + DEFAULT_META, + META_ONE, + META_TWO + ); + } + + @Test + public void should_retrieve_single_annotation_according_to_equals() { + // when + final List actual = sut.findAnnotationsOn(OverrideAnnotatedTwo.class); + + // then + assertThat(actual) + .hasSize(1) + .satisfiesExactlyInAnyOrder(META_TWO); + } + + private AnnotationFinder createFinder() { + return new AnnotationFinder<>(MetaTestAnno.class); + } + + @MetaTestAnno + private static class DirectAnnotated { + } + + @ExtendedTestAnnoOne + private static class ExtendedAnnotatedOne { + } + + @MetaTestAnno + @ExtendedTestAnnoOne + private static class DirectAndExtendedAnnotatedOne { + } + + @MetaTestAnno + @ExtendedTestAnnoOne + @ExtendedTestAnnoTwo + private static class AllIn { + } + + @MetaTestAnno("Two") + @ExtendedTestAnnoTwo + private static class OverrideAnnotatedTwo { + } + + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @Documented + private @interface MetaTestAnno { + String value() default ""; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @MetaTestAnno("One") + private @interface ExtendedTestAnnoOne { + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.TYPE) + @MetaTestAnno("Two") + private @interface ExtendedTestAnnoTwo { + } +}