Maintaining architecture rules and constraints for a specific software project or an application is not easy as textual documentation is easily forgotten after a while and hard to verify.
ArchUnit is a testing library that allows developers and software architects to write down such rules as executable tests that may be run by the development teams and the integration servers.
In the following article I will demonstrate the basic features of this library by applying rules and constraints to an existing application.
Project Setup
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit</artifactId>
<version>0.4.0</version>
<scope>test</scope>
</dependency>
Otherwise when using Gradle, this is our dependency:
testCompile 'com.tngtech.archunit:archunit-junit:0.4.0'
Architecture under Test
Now that our project is basically set up we need an example architecture simple enough to demonstrate ArchUnit’s features.
Our following sample project consists of two Java classes:
-
In package com.hascode.tutorial.comp1 resides class SomeComponent
-
In package com.hascode.tutorial.comp2 resides this class OtherComponent
I have build in a few flaws so that we’re able to verify their presence with a failing architecture test afterwards:
-
SomeComponent is marked as @Deprecated
-
SomeComponent uses the java.util Logger
-
There is a circular reference between SomeComponent and OtherComponent
This is what our sample application’s directory structure looks like:
src/main
├── java
│ └── com
│ └── hascode
│ └── tutorial
│ ├── comp1
│ │ └── SomeComponent.java
│ └── comp2
│ └── OtherComponent.java
└── resources
This is our first class, SomeComponent:
package com.hascode.tutorial.comp1;
import com.hascode.tutorial.comp2.OtherComponent;
import java.util.logging.Logger;
@Deprecated
public class SomeComponent {
OtherComponent comp = new OtherComponent();
Logger log = Logger.getLogger(getClass().getName());
public void foo() {
log.info("logging with java.util logger...");
comp.bar();
}
}
And this our second one____called OtherComponent:
package com.hascode.tutorial.comp2;
import com.hascode.tutorial.comp1.SomeComponent;
public class OtherComponent {
private SomeComponent someComponent;
public OtherComponent() {
someComponent.foo();
}
public void bar() {
}
}
Writing Architectural Tests
Now that our flawed architecture is implemented we may write our first tests.
To implement a test, we’re simply need to..
-
Run our class using the com.tngtech.archunit.junit.ArchUnitRunner (@RunWith(ArchUnitRunner.class)) so that JUnit catches this test
-
Use the class-level annotation com.tngtech.archunit.junit.AnalyzeClasses to specify which packages to read for this test (@AnalyzeClasses(packages=\{..}))
-
Either create a public constant of type com.tngtech.archunit.lang.ArchRule, annotated with @ArchTest (com.tngtech.archunit.junit.ArchTest)
-
Or … create a public static method, annotated with @ArchTest that takes a JavaClasses object as parameter
-
Or .. simply create a normal JUnit test, use the test set-up (@Before/@BeforeClass) to load the JavaClasses using ArchUnits ClassFileImporter, create a @Test annotated method and call the ArchRule’s check method.
In the following examples, I’ll be using the first described approach using a public constant as it is the shortest way for me (lazy) :)
Restricting Package Access
For a simple first start, we want to write a simple rule to restrict the access between classes in sub-package comp2 and classes in sub-package comp1.
The fluent API makes it easy for us to explore the capabilities, for more detailed information, please feel free to consult the project’s documentation.
Using the builder’s because() method allows us to add a more descriptive description for our rule so that we’re able to see why a specific test fails.
package architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = {"com.hascode.tutorial.comp1", "com.hascode.tutorial.comp2"})
public class PackageAccessExampleTest {
@ArchTest
public static final ArchRule COMP2_PACKAGE_MUST_NOT_ACCESS_COMP1 = noClasses().that()
.resideInAPackage("com.hascode.tutorial.comp2").should().accessClassesThat()
.resideInAPackage("com.hascode.tutorial.comp1").because(
"classes in package com.hascode.tutorial.comp2 should not be accessed from classes in package com.hascode.tutorial.comp1");
}
Forbid Deprecated Classes
In our next example, we’re writing a rule that forbids classes in package comp1 that are annotated with @Deprecated.
package architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = {"com.hascode.tutorial.comp1", "com.hascode.tutorial.comp2"})
public class NoDeprecatedClassesExampleTest {
@ArchTest
public static final ArchRule NO_DEPRECATED_CLASSES_IN_COMP1_PACKAGE = noClasses().that()
.areAnnotatedWith(Deprecated.class).should()
.resideInAnyPackage("com.hascode.tutorial.comp1")
.because("deprecated classes should not be allowed in package com.hascode.tutorial.comp1");
}
Forbid Cyclic Dependencies
In our next example, we want to avoid cyclic dependencies between classes in the sub-packages of package com.hascode.tutorial.
For this purpose, we’re using ArchUnit’s slices feature:
package architecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = {"com.hascode.tutorial.comp1", "com.hascode.tutorial.comp2"})
public class CyclicDependenciesExampleTest {
@ArchTest
public static final ArchRule NO_CYCLIC_DEPENDENCIES =
slices().matching("com.hascode.(tutorial).(*)").namingSlices("$2 of $1").should()
.beFreeOfCycles();
}
Using Predefined Rules
ArchUnit offers a set of predefined general coding rules, accessible as constants of the class com.tngtech.archunit.library.GeneralCodingRules .. e.g.:
-
ACCESS_STANDARD_STREAMS
-
NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS
-
THROW_GENERIC_EXCEPTIONS
-
NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS
-
USE_JAVA_UTIL_LOGGING
-
NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING
In our next example, we’ll be using one of this rules, USE_JAVA_UTIL_LOGGING to forbid using the java.util logger:
package architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.GeneralCodingRules.USE_JAVA_UTIL_LOGGING;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = {"com.hascode.tutorial.comp1", "com.hascode.tutorial.comp2"})
public class PredefinedRulesExampleTest {
@ArchTest
public static final ArchRule MUST_NOT_USE_JAVA_UTIL_LOGGING = noClasses()
.should(USE_JAVA_UTIL_LOGGING)
.because("slf4j and logback/log4j2 should be used instead of java.util logger");
}
Writing custom Predicates and Conditions
ArchUnit allows us to write custom predicates to be applied in the filtering process where the JavaClasses for a specific test are determined – we simply extend com.tngtech.archunit.base.DescribedPredicate and pass in a description of our predicate’s filtering.
Under the hood, a described predicate is nothing more than a Guava predicate with a descriptive string.
This is our example predicate that applies to all classes whose package starts with com.hascode.tutorial.comp1.
package com.hascode.tutorial.predicate;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
public class Component1PackagePredicate extends DescribedPredicate {
private static final String PACKAGE = "com.hascode.tutorial.comp1";
public Component1PackagePredicate() {
super("resides in package " + PACKAGE);
}
@Override
public boolean apply(JavaClass javaClass) {
return javaClass.getPackage().startsWith(PACKAGE);
}
}
Conditions may be used to verify conditions and to make a test fail.
They are implemented best by subclassing com.tngtech.archunit.lang.ArchCondition.
Our following simple example creates a condition that verifies, that a given class does not contain any method named "foo".
package com.hascode.tutorial.condition;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
public class NoFooMethodCondition extends ArchCondition {
public NoFooMethodCondition() {
super("not contain a method named foo");
}
@Override
public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
javaClass.getCodeUnits().stream().filter(c -> c.getName().equals("foo"))
.forEach(c -> conditionEvents
.add(SimpleConditionEvent
.violated(c, "class " + javaClass.getName() + " contains a method named foo")));
}
}
Now we’re ready to write a test using our custom predicate and custom condition:
package architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import com.hascode.tutorial.condition.NoFooMethodCondition;
import com.hascode.tutorial.predicate.Component1PackagePredicate;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = {"com.hascode.tutorial"})
public class NoFooMethodConditionExampleTest {
private static final DescribedPredicate IS_IN_COMP_1 = new Component1PackagePredicate();
private static final ArchCondition NO_FOO_METHOD_CONDITION = new NoFooMethodCondition();
@ArchTest
public static final ArchRule NO_METHOD_NAMED_FOO_IN_COMP1 = classes().that(IS_IN_COMP_1).should(
NO_FOO_METHOD_CONDITION);
}
Ignoring a Test
Changing a whole application’s architecture to match some rules is not easy so we might want to disable some of the rules first and refactor our architecture step by step.
Ignoring a test can be achieved by annotating it with @ArchIgnore like this:
package architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchIgnore;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = "com.hascode.tutorial")
public class IgnoringATest {
@ArchIgnore
@ArchTest
public static ArchRule IGNORED_TEST = classes().that()
.resideInAPackage("com.hascode.tutorial.comp1")
.should().notBePublic();
}
Test Code vs Production Code
As in the current implementation, everything in the classpath is scanned according to the definition in @AnalyzeClasses, also test-classes matching one of the packages could be analyzed and lead to an unexpected test result.
We may use import-options here to exclude test-classes from the analysis process like this: @AnalyzeClasses(packages = "…", importOption = ImportOption.DontIncludeTests.class)
The following example fails without the import option, with the option, the condition class which is a test class is ignored.
package architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.junit.ArchUnitRunner;
import com.tngtech.archunit.lang.ArchRule;
import org.junit.runner.RunWith;
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = "com.hascode.tutorial", importOption = ImportOption.DontIncludeTests.class)
public class IgnoringTestClassesTest {
@ArchTest
public static ArchRule NO_TEST_CLASSES = classes().that()
.resideInAPackage("com.hascode.tutorial.condition").should()
.notHaveSimpleName("NoFooMethodCondition");
}
Running the Tests
As we’re using the JUnit runner, we may run our tests with Maven like this:
$ mvn test 1 ↵
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running architecture.ArchitectureTest
Tests run: 4, Failures: 4, Errors: 0, Skipped: 0, Time elapsed: 0.226 sec <<< FAILURE!
Running architecture.NoFooMethodConditionExampleTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.047 sec <<< FAILURE!
Running architecture.PredefinedRulesExampleTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.001 sec <<< FAILURE!
Running architecture.PackageAccessExampleTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.001 sec <<< FAILURE!
Running architecture.NoDeprecatedClassesExampleTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.001 sec <<< FAILURE!
Running architecture.CyclicDependenciesExampleTest
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.008 sec <<< FAILURE!
Results :
Failed tests: COMP2_PACKAGE_MUST_NOT_ACCESS_COMP1(architecture.ArchitectureTest): Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package 'com.hascode.tutorial.comp2' should access classes that reside in a package 'com.hascode.tutorial.comp1', because classes in package com.hascode.tutorial.comp2 should not be accessed from classes in package com.hascode.tutorial.comp1' was violated:
MUST_NOT_USE_JAVA_UTIL_LOGGING(architecture.ArchitectureTest): Architecture Violation [Priority: MEDIUM] - Rule 'no classes should use java.util.logging, because slf4j and logback/log4j2 should be used instead of java.util logger' was violated:
NO_CYCLIC_DEPENDENCIES(architecture.ArchitectureTest): Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'com.hascode.(tutorial).(*)' should be free of cycles' was violated:
NO_DEPRECATED_CLASSES_IN_COMP1_PACKAGE(architecture.ArchitectureTest): Architecture Violation [Priority: MEDIUM] - Rule 'no classes that are annotated with @Deprecated should reside in any package ['com.hascode.tutorial.comp1'], because deprecated classes should not be allowed in package com.hascode.tutorial.comp1' was violated:
NO_METHOD_NAMED_FOO_IN_COMP1(architecture.NoFooMethodConditionExampleTest): Architecture Violation [Priority: MEDIUM] - Rule 'classes that resides in package com.hascode.tutorial.comp1 should not contain a method named <foo>' was violated:
MUST_NOT_USE_JAVA_UTIL_LOGGING(architecture.PredefinedRulesExampleTest): Architecture Violation [Priority: MEDIUM] - Rule 'no classes should use java.util.logging, because slf4j and logback/log4j2 should be used instead of java.util logger' was violated:
COMP2_PACKAGE_MUST_NOT_ACCESS_COMP1(architecture.PackageAccessExampleTest): Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package 'com.hascode.tutorial.comp2' should access classes that reside in a package 'com.hascode.tutorial.comp1', because classes in package com.hascode.tutorial.comp2 should not be accessed from classes in package com.hascode.tutorial.comp1' was violated:
NO_DEPRECATED_CLASSES_IN_COMP1_PACKAGE(architecture.NoDeprecatedClassesExampleTest): Architecture Violation [Priority: MEDIUM] - Rule 'no classes that are annotated with @Deprecated should reside in any package ['com.hascode.tutorial.comp1'], because deprecated classes should not be allowed in package com.hascode.tutorial.comp1' was violated:
NO_CYCLIC_DEPENDENCIES(architecture.CyclicDependenciesExampleTest): Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'com.hascode.(tutorial).(*)' should be free of cycles' was violated:
Tests run: 9, Failures: 9, Errors: 0, Skipped: 0
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
or using an IDE ..
Tutorial Sources
Please feel free to download the tutorial sources from my GitHub repository, fork it there or clone it using Git:
git clone https://github.com/hascode/archunit-tutorial.git
Other Testing Articles of mine
Please feel free to have a look at other testing tutorial of mine (an excerpt):
Alternative: jqAssistant
An interesting alternative might be jqAssistant, a tool that analyzes given projects and stores the information in a Neo4j graph database.
It also allows to enforce constraints for a given system and I have also written an article about it – please feel free to read here: "Software Architecture Exploration and Validation with jqAssistant, Neo4j and Cypher".
Article Updates
-
2017-12-31: Link to jqAssistant tutorial added.
-
2017-10-04: Scope for test-dependencies in Gradle configuration modified.
-
2017-07-03: Examples for ignoring test-scoped classes added.