Architecture tests with ArchUnit, pt. 3: Advanced stuff
11/10/2020
Architecture tests with ArchUnit, pt. 3: Advanced stuff

​​​ArchUnit has many tricks up to its sleeve. We’ve already seen how to check package structure, language elements such as classes, fields, and methods, and how to make sure your layered architecture is sound. But there’s more! Let’s take a look at some advanced conce​pts.

Slicing and dicing

As we’ve seen in the previous part of this series, ArchUnit makes it easy to test layers and their relationships. However, dividing your codebase only horizontally isn’t enough—in all but very tiny projects, you’d end up with huge layers containing too many unrelated classes. Thus we need to divide our code vertically as well, usually by user-facing features and project-specific or general-purpose libraries. Those features or libraries are often compilation units (e.g., Gradle modules), but that doesn’t need to concern us at this moment.

Package and module structure

So, if we have defined several vertical slices of our codebase, we would like to test their relationships as well. Horizontal layer tests work across all slices, so they won’t help us in this case, but ArchUnit has us covered with its high-level slices API:

@ArchTest
val `feature slices should not depend on each other` =
    slices().matching("com.example.myapp.feature.(*)..")
        .should().notDependOnEachOther()

This is a good rule to have, as you usually want your features to be isolated from each other. How does it work?

First, we define the matcher which slices the codebase vertically: It takes package notation which we’ve seen in the previous rules. The matcher group denoted by parentheses specifies the actual slicing point as well as the slice’s name shown in error messages.

In this case, code units residing in the following example packages and their subpackages would constitute separate slices: com.example.myapp.feature.login, com.example.myapp.feature.map or com.example.myapp.feature.navigation.

Feature slices

In contrast, writing matching("com.example.myapp.feature.(**)"), then com.example.myapp.feature.login.model and com.example.myapp.feature.login.data would constitute different slices.

Layer slices

Be careful, as this distinction might be rather subtle!

As always when practicing TDD, you need to see your test fail for the right reason—in this case that means creating a temporary file that intentionally breaks the test and deleting it afterwards.

The rest of the rule is simple: After the usual should() operator, we have only two options: notDependOnEachOther() tests that, well, no slice depends on any other (unlike the layer dependency tests, these tests are bi-directional), whereas beFreeOfCycles() allows dependencies between the slices, but only in one direction at most.

Generally speaking, it may be a good idea to run the beFreeOfCycles() test on every slice (using one of the two test variants mentioned above) in your codebase, whereas some types of slices (typically libraries, but not features) may be permitted to depend on each other in one direction.

But what if your codebase isn’t structured in such a convenient way? For example, there might be no middle feature package distinguishing features from libraries, or worse, the package structure may be completely inconsistent.

For such cases, ArchUnit contains handy SliceAssignment interface which you can use to assign slices to classes in a completely arbitrary way:

@ArchTest
private val features = object : SliceAssignment {
    override fun getIdentifierOf(javaClass: JavaClass) = when {
        javaClass.packageName.startsWith("com.example.myapp.login") -> SliceIdentifier.of("feature-login")
        javaClass.name.contains("map") -> SliceIdentifier.of("feature-map")
        /* ... whatever you need ... */
        else -> SliceIdentifier.ignore()

    override fun getDescription() = "this will be added to the error message"
}

Strings given to SliceIdentifier are arbitrary constants identifying the slice and are also shown in error messages.

There is an important difference in what you write in that else branch: If you return SliceIdentifier.of("remaining"), then all classes not matching the previous cases will be assigned to the "remaining" slice (which means they will be tested against other slices), whereas if you return SliceIdentifier.ignore(), those classes won’t participate in the test at all (both options have their uses, but be careful not to confuse them).

We can then use our slice assignment like this:

slices().assignedFrom(features).should().notDependOnEachOther()

Why be in when you could be out?

As we’ve learned, ArchUnit runs its tests on compiled bytecode. But where do these classes come from?

There is more than one way to specify that, but probably the most succinct is to use this annotation:

@RunWith(ArchUnitRunner::class)
@AnalyzeClasses(packages = ["com.example.myapp"])
internal class MyArchTest

Besides using String literals, we can specify packages with Classes or, if that’s not enough, completely customize the sources using ArchUnit’s LocationProvider. In every case, please note that ArchUnit looks for packages within the current classpath and all classes must be imported for ArchUnit to be able to work correctly—if you import class X, you need to import all its dependencies as well, transitively, otherwise ArchUnit will have only a limited amount of information to work with and the tests might yield false positives, giving you a false sense of security.

Now we have all the class locations that ArchUnit needs, but there are situations when we don’t necessarily need to test against all of the classes in there. We can filter the classes with importOptions:

@RunWith(ArchUnitRunner::class)
@AnalyzeClasses(
    packages = ["com.example.myapp"],
    importOptions = [
        DoNotIncludeArchives::class,
        DoNotIncludeTests::class,
        DoNotIncludeAndroidGeneratedClasses::class
    ]
)
internal class MyArchTest

ArchUnit comes with a couple of handy predefined import options, such as the first two, or we can write our own, which is simple enough:

internal class DoNotIncludeAndroidGeneratedFiles : ImportOption {
    companion object {
        private val pattern = Pattern.compile(".*/BuildConfig\\.class|.*/R(\\\$.*)?\\.class|.*Binding\\.class")
    }

    override fun includes(location: Location) = location.matches(pattern)
}

This import option rejects Android BuildConfig, R, and Binding classes. The location argument passed here is platform-independent, so you don’t have to worry about path separators and such things.

But what if we need to be more granular? For example, sometimes we might need to ignore certain classes on a per-test basis, basically adding ad-hoc exceptions to our pristine rules, because, you know, the real world happens. This is a slippery slope, so don’t forget to document such situations, but it’s simple enough to do by adding or clause to the rule:

@ArchTest
val `domain layer should contain only specified classes` =
classes().that().resideInAPackage("..domain..")
    .should().haveSimpleNameEndingWith("Repository").andShould().beInterfaces()
    .orShould().haveSimpleNameEndingWith("Controller").andShould().beInterfaces()
    .orShould().haveSimpleNameEndingWith("UseCase")
    .orShould().be(Data::class.java)
    .because("only repositories, controllers and use cases are permitted in domain and Data is special wrapper for results from those classes")

Be specific as possible with your exceptions as you don’t want them to accidentally match more language elements than intended—here, using class literal instead of String is safe and future refactoring-proof.

Because clause allows you to add more detail to the default error message generated from the test case name. There is also `as` clause (ArchUnit being written in Java, it accidentally overloads Kotlin’s keyword, so don’t forget to escape it or create an alias extension function with a Kotlin-friendly name) that allows you to completely override the error message.

Godspeed

Because ArchUnit does a lot under the hood (bytecode examination and cycle checks tend to be expensive, among other things), the tests themselves, although written using unit test infrastructure, seldom have the speed of normal unit tests. The speed penalty can be quite severe, so organize your test suites in a way that ArchUnit tests don’t slow you down when developing your code using the classic TDD cycle of red-green-refactor, which has to be always blazingly fast.

That said, all ArchUnit’s features working together allow us to express our architecture in a clean, succinct, and executable way, which is invaluable in larger projects. Consistency and clarity are virtues that may very well make the difference between a codebase that is easy to learn and maintain and one that becomes a big steaming pile of unreadable mess.

Tags

#architecture; #jvm; #tdd; #android

Author

Jiří Hutárek

Versions

ArchUnit 0.14.1
Kotlin 1.3.72