Skip to content

Commit

Permalink
enable @AnalyzeClasses annotation to be used as meta annotation
Browse files Browse the repository at this point in the history
so far users are forced to repeat `@AnalyzeClasses` annotation an every test class.
This cause additional maintenance overhead when common properties (e.g. package structure) changes.
To support the DRY approach, `@AnalzyeClasses` annotation can now be used as meta annotation.

Resolves: #182
Signed-off-by: Mathze <[email protected]>
  • Loading branch information
mathze committed May 11, 2024
1 parent 8e9744b commit f28257a
Show file tree
Hide file tree
Showing 8 changed files with 348 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,14 @@ final class ArchUnitRunnerInternal extends ParentRunner<ArchTestExecution> imple
}

private static AnalyzeClasses checkAnnotation(Class<?> testClass) {
AnalyzeClasses analyzeClasses = testClass.getAnnotation(AnalyzeClasses.class);
ArchTestInitializationException.check(analyzeClasses != null,
List<AnalyzeClasses> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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> 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(),
Expand All @@ -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);
Expand Down Expand Up @@ -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> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
}
}
Original file line number Diff line number Diff line change
@@ -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 {
}
}
Original file line number Diff line number Diff line change
@@ -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<T extends Annotation> {

private final Class<T> annotationClass;

public AnnotationFinder(final Class<T> 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<T> findAnnotationsOn(final Class<?> clazz) {
return findAnnotations(clazz.getAnnotations(), new HashSet<>());
}

private List<T> findAnnotations(final Annotation[] annotations, final HashSet<Annotation> visited) {
final List<T> 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;
}
}
Loading

0 comments on commit f28257a

Please sign in to comment.