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 concepts.
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.
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.
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.
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.