Files
JIMRI/java/test/jmri/TestArchitectureTest.java
2026-06-17 14:00:51 +02:00

322 lines
14 KiB
Java

package jmri;
import java.lang.annotation.Annotation;
import java.util.stream.Stream;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.netbeans.jemmy.QueueTool;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.lang.*;
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
import com.tngtech.archunit.junit.*;
import static com.tngtech.archunit.lang.conditions.ArchConditions.callMethod;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
/**
* Check the architecture of the JMRI library Tests.
* <p>
* This is run as part of CI, so it's expected to kept passing at all times.
* <p>
* Note that this only checks the classes in target/test-classes, which come from java/test, not
* the ones in target/classes, which come from java/src. It's relying on the common
* build procedure to make this distinction.
* Based on {@link ArchitectureTest}
*
* See examples in the <a href='https://github.com/TNG/ArchUnit-Examples/tree/master/example-plain/src/test/java/com/tngtech/archunit/exampletest">ArchUnit sample code</a>.
*
* @author Bob Jacobsen 2019
* @author Steve Young Copyright (c) 2022
*/
// Pick up all classes from the target/test-classes directory, which is the test code
@AnalyzeClasses(packages = {"target/test-classes"}) // "jmri","apps"
public class TestArchitectureTest {
// want these statics first in class, to initialize
// logging before various static items are constructed
@BeforeAll // tests are static.
public static void setUp() {
jmri.util.JUnitUtil.setUp();
}
@AfterAll
public static void tearDown() {
jmri.util.JUnitUtil.tearDown();
}
/**
* Prevent @RepeatedTest annotations from being accidentally merged.
*/
@ArchTest
public static final ArchRule repeatedTestRule = noMethods().should()
.beAnnotatedWith(org.junit.jupiter.api.RepeatedTest.class);
/**
* Please use org.junit.jupiter.api.Test
*/
@ArchTest
public static final ArchRule junit4TestRule = noClasses()
.should().dependOnClassesThat().haveFullyQualifiedName("org.junit.Test") // JUnit4
.because("Tests should normally use org.junit.jupiter.api.Test"); // JUnit5
@ArchTest
public static final ArchRule methodsStartingWithTestShouldBeAnnotatedWithTest =
ArchRuleDefinition.methods()
.that(new DescribedPredicate<JavaMethod>("name starts with 'test', missing test annotations") {
@Override
public boolean test(JavaMethod method) {
boolean nameMatches = method.getName().toLowerCase().startsWith("test");
boolean noParams = method.getRawParameterTypes().isEmpty();
boolean notAbstract = !method.getModifiers().contains(JavaModifier.ABSTRACT);
boolean isVoid = method.getRawReturnType().isEquivalentTo(void.class);
boolean hasAnyTestAnnotation =
method.isAnnotatedWith( ParameterizedTest.class);
// Return true for methods that start with test, have no params, AND are missing the annotations
return nameMatches && noParams && notAbstract && isVoid && !hasAnyTestAnnotation;
}
})
// Point failures towards org.junit.jupiter.api.Test , not Parameterized / Abstract
.should().beAnnotatedWith(Test.class);
/**
* Please use org.junit.jupiter.api.BeforeEach
*/
@ArchTest
public static final ArchRule junit4BeforeRule = noClasses()
.should().dependOnClassesThat().haveFullyQualifiedName("org.junit.Before") // JUnit4
.because("Tests should normally use org.junit.jupiter.api.BeforeEach"); // JUnit5
/**
* Please use org.junit.jupiter.api.AfterEach
*/
@ArchTest
public static final ArchRule junit4AfterRule = noClasses()
.should().dependOnClassesThat().haveFullyQualifiedName("org.junit.After") // JUnit4
.because("Tests should normally use org.junit.jupiter.api.AfterEach"); // JUnit5
/**
* please use org.junit.jupiter.api.Disabled
*/
@ArchTest
public static final ArchRule junit4IgnoreRule = noClasses()
.should().dependOnClassesThat().haveFullyQualifiedName("org.junit.Ignore") // JUnit4
.because("To Ignore, tests should use org.junit.jupiter.api.Disabled or jmri.util.junit.annotations.NotApplicable"); // JUnit5
/**
* JMRI should not reference org.apache.log4j to allow JMRI
* to be used as library in applications that choose not to use Log4J.
*/
@ArchTest
public static final ArchRule noLog4JinJmriTestsRule = noClasses()
.that().doNotHaveFullyQualifiedName("jmri.util.JUnitAppender")
.and().doNotHaveFullyQualifiedName("jmri.util.JUnitAppenderTest")
.and().doNotHaveFullyQualifiedName("jmri.util.TestingLoggerConfiguration")
.and().doNotHaveFullyQualifiedName("apps.jmrit.log.Log4JTreePaneTest")
.should().dependOnClassesThat().resideInAPackage("org.apache.logging.log4j")
.because("Tests should normally use org.slf4j.Logger instead of Log4J");
/**
* JMRI tests should use org.slf4j.Logger instead of JUL.
*/
@ArchTest
public static final ArchRule minimalJULinJmriTests = noClasses()
.that().resideInAPackage("jmri..")
.and().doNotHaveFullyQualifiedName("jmri.util.TestingLoggerConfiguration") // tests jul routed to l4j OK
.and().doNotHaveFullyQualifiedName("jmri.util.web.BrowserFactory") // 3rd party lib setup
.and().doNotHaveFullyQualifiedName("jmri.web.WebServerAcceptanceSteps") // testing output from lib
.should().dependOnClassesThat().resideInAPackage("java.util.logging")
.because("Tests should normally use org.slf4j.Logger instead of JUL");
/**
* setUp methods should normally use the org.junit.jupiter.api.BeforeEach annotation.
*/
@ArchTest
public static final ArchRule setUpMethodsHaveBeforeEachAnnotation =
methods()
.that().haveName("setUp")
.and().doNotHaveModifier(JavaModifier.ABSTRACT)
.and().areNotDeclaredIn(jmri.util.JUnitUtil.class)
.should()
.beAnnotatedWith(BeforeEach.class)
.orShould().beAnnotatedWith(BeforeAll.class)
.because("setUp methods should normally use the BeforeEach annotation");
/**
* tearDown methods should normally use the org.junit.jupiter.api.AfterEach annotation.
*/
@ArchTest
public static final ArchRule tearDownMethodsHaveAfterEachAnnotation =
methods()
.that().haveName("tearDown")
.and().doNotHaveModifier(JavaModifier.ABSTRACT)
.and().areNotDeclaredIn(jmri.util.JUnitUtil.class)
.should()
.beAnnotatedWith(AfterEach.class)
.orShould().beAnnotatedWith(AfterAll.class)
.because("tearDown methods should normally use the AfterEach annotation");
/**
* JMRI does not require PackageTest.class .
*/
@ArchTest
public static final ArchRule noJUnit4PackageTestsRule = noClasses()
.should().haveSimpleName("PackageTest")
.because("JMRI does not require PackageTest.class");
/**
* JUnit5 should not have abstract methods with Test annotation.
* Instead, the overriding method should have the Test annotation.
*/
@ArchTest
public static final ArchRule noAbstractTestMethods = noMethods()
.that().areAnnotatedWith(Test.class)
.or().areAnnotatedWith(org.junit.jupiter.params.ParameterizedTest.class)
.should()
.haveModifier(JavaModifier.ABSTRACT)
.because("The overriding method should have the Test annotation, not the abstract.");
/**
* JUnit5 should not have abstract methods with LifeCycle annotation.
* Instead, the overriding method should have the annotation.
*/
@ArchTest
public static final ArchRule noAbstractLifeCycleMethods = noMethods()
.that().areAnnotatedWith(BeforeEach.class)
.or().areAnnotatedWith(AfterEach.class)
.or().areAnnotatedWith(BeforeAll.class)
.or().areAnnotatedWith(AfterAll.class)
.should()
.haveModifier(JavaModifier.ABSTRACT)
.because("The overriding method should have the Test Lifecycle annotation, not the abstract.");
/**
* Tests should not have empty methods
* as this saves invoking both the setUp and tearDown Class methods.
*/
@ArchTest
public static final ArchRule no_empty_test_methods =
ArchRuleDefinition.methods()
.that().areAnnotatedWith(Test.class)
.and().areNotAnnotatedWith(Disabled.class)
.and().areNotAnnotatedWith(jmri.util.junit.annotations.NotApplicable.class)
// java assert may be removed in compilation resulting in an empty method
.and().areNotDeclaredIn(jmri.util.junit.AssertTest.class)
.should(notBeEmpty())
.because("this saves invoking both the setUp and tearDown Class methods. "
+"Please use @Disabled or @jmri.util.junit.annotations.NotApplicable");
private static ArchCondition<JavaMethod> notBeEmpty() {
return new ArchCondition<>("not be empty") {
@Override
public void check(JavaMethod method, ConditionEvents events) {
if (method.getMethodCallsFromSelf().isEmpty() && method.getRawParameterTypes().isEmpty()) {
String message = String.format("Method %s is empty", method.getFullName());
events.add(SimpleConditionEvent.violated(method, message));
}
}
};
}
/**
* Tests should use JUnit assertions, not native Java asserts.
* Note that although we cannot directly test for usage of the assert keyword,
* we can check for classes that use java.lang.AssertionError.class
*/
@ArchTest
public static final ArchRule noNativeAsserts = noClasses()
.that().doNotHaveFullyQualifiedName("jmri.util.junit.AssertTest")
.should().accessClassesThat()
.haveFullyQualifiedName(AssertionError.class.getName())
.because("Please use JUnit Assertions rather than the Java 'assert' keyword");
@ArchTest
public static final ArchRule noQueueToolTimedWaitEmpty = noClasses()
.that().doNotHaveFullyQualifiedName("jmri.jmrit.operations.OperationsTestCase") // used in non-CI tearDown testing
.should( callMethod(QueueTool.class, "waitEmpty", long.class))
.because("Due to timers or cursor flashes, the queue may never become empty for a duration. "
+ "Please call new QueueTool().waitEmpty() without a timeout, or use JUnitUtil.waitFor(xx)");
@ArchTest
public static final ArchRule tests_should_use_JUnitUtil = classes()
.that().areNotInterfaces()
.and().doNotHaveModifier(JavaModifier.ABSTRACT ) // exclude abstract classes
.should(haveJUnitUtilSetupAndTeardown());
private static ArchCondition<JavaClass> haveJUnitUtilSetupAndTeardown() {
return new SetupTearDownCondition("have JUnitUtil.setUp/tearDown in @BeforeEach/@AfterEach (or super)");
}
private static class SetupTearDownCondition extends ArchCondition<JavaClass> {
SetupTearDownCondition(String description) {
super(description);
}
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
if (!isJUnitTestClass(javaClass) || isBundleTest(javaClass) ) {
return;
}
boolean hasSetup =
hasAnnotatedMethodCallingJUnitUtil(javaClass, BeforeEach.class, "setUp")
|| hasAnnotatedMethodCallingJUnitUtil(javaClass, BeforeAll.class, "setUp")
|| hasAnnotatedMethodCallingJUnitUtil(javaClass, BeforeEach.class, "setUpLoggingAndCommonProperties")
|| hasAnnotatedMethodCallingJUnitUtil(javaClass, BeforeAll.class, "setUpLoggingAndCommonProperties");
boolean hasTeardown =
hasAnnotatedMethodCallingJUnitUtil(javaClass, AfterEach.class, "tearDown")
|| hasAnnotatedMethodCallingJUnitUtil(javaClass, AfterAll.class, "tearDown");
if (!hasSetup) {
events.add(SimpleConditionEvent.violated( javaClass,
javaClass.getName() + " has tests but no @BeforeEach or super calling JUnitUtil.setUp()"
));
}
if (!hasTeardown) {
events.add(SimpleConditionEvent.violated( javaClass,
javaClass.getName() + " has tests but no @AfterEach or super calling JUnitUtil.tearDown()"
));
}
}
}
private static boolean isJUnitTestClass(JavaClass c) {
return c.getMethods().stream().anyMatch(m ->
m.isAnnotatedWith( Test.class)
|| m.isAnnotatedWith(ArchTest.class)
|| m.isAnnotatedWith( ParameterizedTest.class));
}
private static boolean isBundleTest(JavaClass c) {
return c.getSimpleName().endsWith("BundleTest");
}
private static final String JUNIT_UTIL = "jmri.util.JUnitUtil";
private static boolean hasAnnotatedMethodCallingJUnitUtil( JavaClass clazz,
Class<? extends Annotation> annotationType, String junitUtilMethodName) {
// Build a stream of this class plus all its raw superclasses
Stream<JavaClass> classesToCheck =
Stream.concat(Stream.of(clazz), clazz.getAllRawSuperclasses().stream());
return classesToCheck
.flatMap(c -> c.getMethods().stream())
.filter(m -> m.isAnnotatedWith(annotationType))
.anyMatch(m -> m.getMethodCallsFromSelf().stream().anyMatch(call ->
call.getTargetOwner().getName().equals(JUNIT_UTIL)
&& call.getName().equals(junitUtilMethodName)));
}
}