Skip to content

Commit

Permalink
enable @AnalyzeClasses annotation to be used as meta annotation (#1300
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 causes additional maintenance overhead when common properties (e.g. package structure) change.
To support the DRY approach, the `@AnalzyeClasses` annotation can now be used as a meta-annotation.

Resolves: #182
  • Loading branch information
codecholeric authored Jan 19, 2025
2 parents 477144a + 9e90a3b commit 73dfe66
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 62 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import static com.tngtech.archunit.junit.internal.ArchRuleDeclaration.elementShouldBeIgnored;
import static com.tngtech.archunit.junit.internal.ArchRuleDeclaration.toDeclarations;
import static com.tngtech.archunit.junit.internal.ArchTestExecution.getValue;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.findAnnotation;
import static java.util.Collections.singleton;
import static java.util.Collections.singletonList;
import static java.util.stream.Collectors.toList;
Expand All @@ -53,7 +54,7 @@ final class ArchUnitRunnerInternal extends ParentRunner<ArchTestExecution> imple

ArchUnitRunnerInternal(Class<?> testClass) throws InitializationError {
super(testClass);
checkAnnotation(testClass);
checkTestRunnable(testClass);

try {
ArchUnitSystemPropertyTestFilterJunit4.filter(this);
Expand All @@ -62,12 +63,8 @@ final class ArchUnitRunnerInternal extends ParentRunner<ArchTestExecution> imple
}
}

private static AnalyzeClasses checkAnnotation(Class<?> testClass) {
AnalyzeClasses analyzeClasses = testClass.getAnnotation(AnalyzeClasses.class);
ArchTestInitializationException.check(analyzeClasses != null,
"Class %s must be annotated with @%s",
testClass.getSimpleName(), AnalyzeClasses.class.getSimpleName());
return analyzeClasses;
private static void checkTestRunnable(Class<?> testClass) {
findAnnotation(testClass, AnalyzeClasses.class);
}

@Override
Expand Down Expand Up @@ -180,7 +177,7 @@ private static class JUnit4ClassAnalysisRequest implements ClassAnalysisRequest
private final AnalyzeClasses analyzeClasses;

JUnit4ClassAnalysisRequest(Class<?> testClass) {
analyzeClasses = checkAnnotation(testClass);
analyzeClasses = findAnnotation(testClass, AnalyzeClasses.class);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.tngtech.archunit.junit.internal;

import java.lang.annotation.Retention;
import java.util.Set;

import com.tngtech.archunit.core.domain.JavaClass;
Expand Down Expand Up @@ -28,6 +29,7 @@

import static com.tngtech.archunit.core.domain.TestUtils.importClasses;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
Expand All @@ -49,7 +51,9 @@ public class ArchUnitRunnerTest {
@InjectMocks
private ArchUnitRunnerInternal runner = newRunner(SomeArchTest.class);
@InjectMocks
private ArchUnitRunnerInternal runnerOfMaxTest = newRunner(MaxAnnotatedTest.class);
private ArchUnitRunnerInternal runnerOfMaxAnnotatedTest = newRunner(MaxAnnotatedTest.class);
@InjectMocks
private ArchUnitRunnerInternal runnerOfMetaAnnotatedTest = newRunner(MetaAnnotatedTest.class);

@Before
public void setUp() {
Expand All @@ -58,7 +62,7 @@ public void setUp() {

@Test
public void runner_creates_correct_analysis_request() {
runnerOfMaxTest.run(new RunNotifier());
runnerOfMaxAnnotatedTest.run(new RunNotifier());

verify(cache).getClassesToAnalyzeFor(eq(MaxAnnotatedTest.class), analysisRequestCaptor.capture());

Expand Down Expand Up @@ -92,10 +96,26 @@ public void rejects_missing_analyze_annotation() {
)
.isInstanceOf(ArchTestInitializationException.class)
.hasMessageContaining(Object.class.getSimpleName())
.hasMessageContaining("must be annotated")
.hasMessageContaining("is not (meta-)annotated")
.hasMessageContaining(AnalyzeClasses.class.getSimpleName());
}

@Test
public void runner_creates_correct_analysis_request_for_meta_annotated_class() {
runnerOfMetaAnnotatedTest.run(new RunNotifier());

verify(cache).getClassesToAnalyzeFor(eq(MetaAnnotatedTest.class), analysisRequestCaptor.capture());

AnalyzeClasses analyzeClasses = MetaAnnotatedTest.class.getAnnotation(MetaAnnotatedTest.MetaAnalyzeClasses.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());
}

private ArchUnitRunnerInternal newRunner(Class<?> testClass) {
try {
return new ArchUnitRunnerInternal(testClass);
Expand Down Expand Up @@ -160,4 +180,19 @@ public static class MaxAnnotatedTest {
public static void someTest(JavaClasses classes) {
}
}

@MetaAnnotatedTest.MetaAnalyzeClasses
public static class MetaAnnotatedTest {
@ArchTest
public static void someTest(JavaClasses classes) {
}

@Retention(RUNTIME)
@AnalyzeClasses(
packages = {"com.foo", "com.bar"},
wholeClasspath = true
)
public @interface MetaAnalyzeClasses {
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@
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.findAnnotation;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.getAllFields;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.getAllMethods;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.getValueOrThrowException;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.invokeMethod;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.tryFindAnnotation;
import static com.tngtech.archunit.junit.internal.ReflectionUtils.withAnnotation;

class ArchUnitTestDescriptor extends AbstractArchUnitTestDescriptor implements CreatesChildren {
Expand All @@ -71,8 +72,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) {
LOG.warn("Class {} is not annotated with @{} and thus cannot run as a top level test. "
if (!tryFindAnnotation(clazz, AnalyzeClasses.class).isPresent()) {
LOG.warn("Class {} is not (meta-)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(),
clazz.getSimpleName(), ArchTests.class.getSimpleName(), clazz.getSimpleName());
Expand Down Expand Up @@ -291,15 +292,7 @@ private static class JUnit5ClassAnalysisRequest implements ClassAnalysisRequest
private final AnalyzeClasses analyzeClasses;

JUnit5ClassAnalysisRequest(Class<?> testClass) {
analyzeClasses = checkAnnotation(testClass);
}

private static AnalyzeClasses checkAnnotation(Class<?> testClass) {
AnalyzeClasses analyzeClasses = testClass.getAnnotation(AnalyzeClasses.class);
checkArgument(analyzeClasses != null,
"Class %s must be annotated with @%s",
testClass.getSimpleName(), AnalyzeClasses.class.getSimpleName());
return analyzeClasses;
analyzeClasses = findAnnotation(testClass, AnalyzeClasses.class);
}

@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 Down Expand Up @@ -169,6 +170,20 @@ void a_single_test_class() {
assertThat(child.getParent().get()).isEqualTo(descriptor);
}

@Test
void a_test_class_with_meta_annotated_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 +520,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 +1089,21 @@ void cache_is_cleared_afterwards() {
verify(classCache, atLeastOnce()).getClassesToAnalyzeFor(any(Class.class), any(ClassAnalysisRequest.class));
verifyNoMoreInteractions(classCache);
}

@Test
void a_class_with_analyze_classes_as_meta_annotation() {
execute(createEngineId(), TestClassWithMetaAnnotationForAnalyzeClasses.class);

verify(classCache).getClassesToAnalyzeFor(eq(TestClassWithMetaAnnotationForAnalyzeClasses.class), classAnalysisRequestCaptor.capture());
ClassAnalysisRequest request = classAnalysisRequestCaptor.getValue();
AnalyzeClasses expected = TestClassWithMetaAnnotationForAnalyzeClasses.class.getAnnotation(TestClassWithMetaAnnotationForAnalyzeClasses.MetaAnalyzeClasses.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 Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.tngtech.archunit.junit.internal.testexamples;

import java.lang.annotation.Retention;

import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static java.lang.annotation.RetentionPolicy.RUNTIME;

@TestClassWithMetaAnnotationForAnalyzeClasses.MetaAnalyzeClasses
public class TestClassWithMetaAnnotationForAnalyzeClasses {

@ArchTest
public static final ArchRule rule_in_class_with_meta_analyze_class_annotation = RuleThatFails.on(UnwantedClass.class);

@Retention(RUNTIME)
@AnalyzeClasses(wholeClasspath = true)
public @interface MetaAnalyzeClasses {
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.tngtech.archunit.junit.internal;

class ArchTestInitializationException extends RuntimeException {
private ArchTestInitializationException(String message, Object... args) {
ArchTestInitializationException(String message, Object... args) {
super(String.format(message, args));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
Expand All @@ -38,20 +40,6 @@ class ReflectionUtils {
private ReflectionUtils() {
}

static Set<Class<?>> getAllSupertypes(Class<?> type) {
if (type == null) {
return Collections.emptySet();
}

ImmutableSet.Builder<Class<?>> result = ImmutableSet.<Class<?>>builder()
.add(type)
.addAll(getAllSupertypes(type.getSuperclass()));
for (Class<?> c : type.getInterfaces()) {
result.addAll(getAllSupertypes(c));
}
return result.build();
}

static Collection<Field> getAllFields(Class<?> owner, Predicate<? super Field> predicate) {
return getAll(owner, Class::getDeclaredFields).filter(predicate).collect(toList());
}
Expand All @@ -68,18 +56,22 @@ private static <T> Stream<T> getAll(Class<?> type, Function<Class<?>, T[]> colle
return result.build();
}

static <T> T newInstanceOf(Class<T> type) {
return com.tngtech.archunit.base.ReflectionUtils.newInstanceOf(type);
}
private static Set<Class<?>> getAllSupertypes(Class<?> type) {
if (type == null) {
return Collections.emptySet();
}

@SuppressWarnings("unchecked") // callers must know, what they do here, we can't make this compile safe anyway
private static <T> T getValue(Field field, Object owner) {
try {
field.setAccessible(true);
return (T) field.get(owner);
} catch (IllegalAccessException e) {
throw new ReflectionException(e);
ImmutableSet.Builder<Class<?>> result = ImmutableSet.<Class<?>>builder()
.add(type)
.addAll(getAllSupertypes(type.getSuperclass()));
for (Class<?> c : type.getInterfaces()) {
result.addAll(getAllSupertypes(c));
}
return result.build();
}

static <T> T newInstanceOf(Class<T> type) {
return com.tngtech.archunit.base.ReflectionUtils.newInstanceOf(type);
}

static <T> T getValueOrThrowException(Field field, Class<?> fieldOwner, Function<Throwable, ? extends RuntimeException> exceptionConverter) {
Expand All @@ -94,6 +86,16 @@ static <T> T getValueOrThrowException(Field field, Class<?> fieldOwner, Function
}
}

@SuppressWarnings("unchecked") // callers must know what they do here, we can't make this compile safe anyway
private static <T> T getValue(Field field, Object owner) {
try {
field.setAccessible(true);
return (T) field.get(owner);
} catch (IllegalAccessException e) {
throw new ReflectionException(e);
}
}

static <T> T invokeMethod(Method method, Class<?> methodOwner, Object... args) {
if (Modifier.isStatic(method.getModifiers())) {
return invoke(null, method, args);
Expand All @@ -102,7 +104,7 @@ static <T> T invokeMethod(Method method, Class<?> methodOwner, Object... args) {
}
}

@SuppressWarnings("unchecked") // callers must know, what they do here, we can't make this compile safe anyway
@SuppressWarnings("unchecked") // callers must know what they do here, we can't make this compile safe anyway
private static <T> T invoke(Object owner, Method method, Object... args) {
method.setAccessible(true);
try {
Expand All @@ -124,4 +126,41 @@ private static <T extends Throwable> void rethrowUnchecked(Throwable throwable)
static Predicate<AnnotatedElement> withAnnotation(Class<? extends Annotation> annotationType) {
return input -> input.isAnnotationPresent(annotationType);
}

/**
* Same as {@link #tryFindAnnotation(Class, Class)}, but throws an exception if no annotation can be found.
*/
static <T extends Annotation> T findAnnotation(final Class<?> clazz, Class<T> annotationType) {
return tryFindAnnotation(clazz, annotationType).orElseThrow(() ->
new ArchTestInitializationException("Class %s is not (meta-)annotated with @%s", clazz.getName(), annotationType.getSimpleName()));
}

/**
* Recursively searches for an annotation of type {@link T} on the given {@code clazz}.
* Returns the first matching annotation that is found.
* Any further matching annotation possibly present within the meta-annotation hierarchy will be ignored.
* If no matching annotation can be found {@link Optional#empty()} will be returned.
*
* @param clazz The {@link Class} from which to retrieve the annotation
* @return The first found annotation instance reachable in the meta-annotation hierarchy or {@link Optional#empty()} if none can be found
*/
static <T extends Annotation> Optional<T> tryFindAnnotation(final Class<?> clazz, Class<T> annotationType) {
return tryFindAnnotation(clazz.getAnnotations(), annotationType, new HashSet<>());
}

private static <T extends Annotation> Optional<T> tryFindAnnotation(final Annotation[] annotations, Class<T> annotationType, Set<Annotation> visited) {
for (Annotation annotation : annotations) {
if (!visited.add(annotation)) {
continue;
}

Optional<T> result = annotationType.isInstance(annotation)
? Optional.of(annotationType.cast(annotation))
: tryFindAnnotation(annotation.annotationType().getAnnotations(), annotationType, visited);
if (result.isPresent()) {
return result;
}
}
return Optional.empty();
}
}
Loading

0 comments on commit 73dfe66

Please sign in to comment.