Unit test your software architecture using JUnit and ArchUnit

Java featured image

We all care about testing right? I’m pretty sure that everyone has at least done some unit testing using JUnit, it’s useful to increase your code coverage and prevent regressions.

Most importantly, testing your application is required to increase software quality and reduce maintenance cost.

We will not be discussing benefits of software testing here, but for curious readers, here is one of the many articles in the web about benefits of software testing Benefits Of Software Testing.

In this post we will present a relatively new type of testing, which is architecture testing, using JUnit and a powerful framework called ArchUnit.

Basically, in a typical Java application, architecture testing refers to dependencies check between packages/layers and classes.

Prequisites

The following article requires a knowledge of the following technologies:

Why Do We Need Architecture Testing

In the DevOps pipeline, you need design rules, metrics, and tests (like Netflix chaos monkey) that continuously monitor the health of the software.

As Neil Ford (Software Architect at ThoughtWorks) said:

You can build architectures that evolve, it doesn’t take a Herculean amount of effort. In fact, you can incrementally start applying these ideas [continuous architectural testing] to your existing architecture.

ArchUnit provides one of the many ways to add design rules to a Java application, this safeguards the architecture and creates a protective layer around your software as it evolves.

When included in the integration pipeline, you can automatically assure that architectural rules are respected after each commit.

How ArchUnit Works

ArchUnit is a Java library, divided into three different layers:

  • Core: it’s a layer on top of the Java Reflection API, it provides primitives like: JavaClasses, JavaMethod, JavaMethodCall..
  • Lang: fluent and high level API for expressing rules
  • Library: complex and predefined rules, provide a concise API to deal with common patterns

For better understanding of the relations between the layers, we could express a quite similar rule using both the Lang and the Library API.

For example, In an application where we have a Service and Dao package, the rule below states that classes in Service package should not access classes in Dao package:

Lang API

noClasses()
.that().resideInAPackage("..example.service..")
.should().accessClassesThat().resideInAnyPackage("..example.dao..").check(javaClasses);

Library API

SlicesRuleDefinition.slices().matching("..example.(**)").should().notDependOnEachOther()

Let’s code

Installation

ArchUnit can be obtained from Maven Central.

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit</artifactId>
    <version>0.10.1</version>
    <scope>test</scope>
</dependency>

Fluent API

As stated earlier, the API is fluent, it’s is very intuitive and also helpful, as the IDE will provide suggestions about what can be done next.

archunit-fluent-api-ide

The statements follows the BDD (Behavior Driven Development) style using the given/when/then approach, where each statement can be split into two parts. Given the following statement:

classes().that().haveNameMatching(".*Repository")
.should().onlyBeAccessed().byClassesThat().haveNameMatching(".*Service");

The part before the should() call represents both the “given” and the “when” of the BDD style, whereas the remaining part represents the “then”.

JUnit support

While ArchUnit can be used with any unit testing framework, it provides extended support for writing tests with JUnit 4 and JUnit 5.

Tests look and behave very similar between JUnit 4 and 5. The only difference is, that with JUnit 4 it is necessary to add a specific Runner to take care of caching and checking rules. Whereas JUnit 5 picks up the respective TestEngine transparently.

Typically you should annotate your test class by the following:

@RunWith(ArchUnitRunner.class) 
@AnalyzeClasses(packages = "com.indev.archunit")

The AnalyseClasses annotation is used to specify the root package of the classes you want to test, they will be imported and cached between test executions.

Moreover we should annotate each test method with:

@ArchTest

The following section illustrates some typical checks you could do with ArchUnit, however in real world scenario you may have to mix multiple checks.

The examples above are taken from of a simple web application built using spring boot, where we have three different packages: Repository, Service and Controller.

Class and package dependency checks

By using the class attributes like name, package and modifiers, this code bellow is checking that repositories are only accessed by services:

    @ArchTest
    public void classDependencyCheck(JavaClasses javaClasses) {
        classes().that().haveNameMatching(".*Repository")
                .should().onlyBeAccessed().byClassesThat().haveNameMatching(".*Service").check(javaClasses);
    }

The next code snippet is checking few rules about controllers:

  • depends on services
  • extends AbstractController class
  • not be package private
  • be annotated with @Controller
@ArchTest
    public void controllers_rules(JavaClasses javaClasses) {
        classes().that().resideInAnyPackage("..controller").should()
                .dependOnClassesThat().resideInAnyPackage("..service..")
                .andShould().beAssignableTo(AbstractController.class)
                .andShould().notBePackagePrivate()
                .andShould().beAnnotatedWith(Controller.class)
                .check(javaClasses);
    }

Method calls checks

   @ArchTest
    public void controller_should_only_call_methods_declared_in_controllers_or_services(JavaClasses javaClasses) {
        classes()
                .that().resideInAPackage("..controller..")
                .should().onlyCallCodeUnitsThat(areDeclaredInControllerOrService()).check(javaClasses);
    }

    private static DescribedPredicate<JavaMember> areDeclaredInControllerOrService() {
        DescribedPredicate<JavaClass> aPackageController = GET_PACKAGE_NAME.is(PackageMatchers.of("..controller..", "..service..", "java.."))
                .as("a package '..controller..'");
        return are(declaredIn(aPackageController));
    }

The new method here, areDeclaredInControllerOrService, is responsible for creating a custom predicate, that will apply on a JavaMember.

The JavaMember can be any member of a java class so the predicate will return true whenever the java member is in the specified packages.

Therefore the test method is checking that all controllers are only calling code units that reside in the specified packages.

The packages list includes the ..java, it’s required since the code units may be methods or constructors of the java SDK itself, including the Object.new() constructor call that occurs whenever we create an object.

Layers checks

@Test
    public void services_should_only_be_depended_on_by_controllers_or_other_services() {
        classes().that().resideInAPackage("..service..")
                .should().onlyHaveDependentClassesThat().resideInAnyPackage("..controller..", "..service..").check(classes);
    }

    @Test
    public void services_should_only_depend_on_persistence_or_other_services() {
        classes().that().resideInAPackage("..service..")
                .should().onlyDependOnClassesThat().resideInAnyPackage("..service..", "..persistence..", "java..").check(classes);
    }

You can find very nice examples in ArchUnit-Examples.

Conclusion

Architecture testing using ArcUnit allow checking architecture characteristics such as package and class dependencies, annotation verification and even layer consistency.

It has some real benefits:

  • with the JUnit support, it runs as unit tests within your existing test setup.
  • can be incorporated into a CI environment or a deployment pipeline.
  • has a simple and fluent API for writing rules.
  • provide ready to use rules and can be easily extended

 

5 2 votes
Article Rating
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x