In the first part of this series, we’ve had a glimpse
of an architecture test written with the almighty ArchUnit, but of course there’s much more!
Although ArchUnit’s API looks like Java Reflection API, it also contains powerful constructs to
describe dependencies between code or predefined rules for testing popular architecture styles.
Let’s see what we’ve got to play with!
First things first
ArchUnit rules follow the pattern
(no) LANGUAGE_ELEMENT that PREDICATE should (not) CONDITION.
So what language elements can we use?
All tests begin with static methods in ArchRuleDefinition class
(but please import the class to make the rules more readable).
We can start with classes or constructors
which are pretty self-explanatory. We also have theClass if you
want to be brief and specific. If possible, always use the overload that takes Class<*> argument instead of the overload that takes
String to make your tests resistant to future refactorings; the same goes for other methods with
these argument choices.
Next, we have fields, methods and
members. When testing Kotlin code, be extra careful with fields because Kotlin properties are not Java
fields. Remember that ArchUnit checks compiled bytecode and every Kotlin property is actually
compiled to getter method by prepending the get prefix, setter
method by prepending the set prefix (only for var properties) and private field with the same name as the
property name, but only for properties with backing fields. When testing Kotlin
properties, it may sometimes be safer to test their generated getters or setters. Anyway, these
subtle details show the importance of watching your test fail.
We also have a slightly mysterious codeUnits method—it means
simply anything that can access other code (including methods, constructors, initializer blocks,
static field assignments etc.).
All methods mentioned above also have their negated variants. Now what can we do with all
this?
Packages, packages everywhere
Consistent packaging is one of the most important things to get right in the project. We strongly
prefer packaging by features first, then by layers. This concept sometimes goes by the name of
“screaming architecture”: For example, when you open an Android project and you see top level
packages such as map, plannedtrips, routeplanning,
speedlimits, tolls, vehicles or voiceguidance,
you’ll get a pretty good idea about what the app is really about. But if instead you are looking at
packages such as activities, fragments, services, di, data, apis, etc., it won’t tell you much about the
application (every Android app will contain at least some of those things).
ArchUnit can enforce correct package structure, prevent deadly cyclic dependencies and much more.
Let’s see a few examples (the actual packages mentioned are not important, use what is
convenient for your project):
@ArchTest
val `every class should reside in one of the specified packages` =
classes().should().resideInAnyPackage(
"..di",
"..ui",
"..presentation",
"..domain",
"..data"
)
The two dots mean “any number of packages including zero”, so this test says that every class
must exist in one of these predefined leaf packages.
This test however doesn’t say anything about the package structure above the leaves, so
if you want to be more strict, you can write this, for example:
@ArchTest
val `every class should reside in one of the specified packages` =
classes().should().resideInAnyPackage(
"com.example.myapp.*.di",
"com.example.myapp.*.ui",
"com.example.myapp.*.presentation",
"com.example.myapp.*.domain",
"com.example.myapp.*.data"
)
The star matches any sequence of characters excluding the dot (for our sample packaging, in its
place there would be a feature name), but you can also use ** which
matches any sequence of characters including the dot. Together with the two dot
notation, you can express pretty much any package structure conceivable (see the Javadoc for
PackageMatcher class).
Building the walls
One popular architectural style is to divide the code into layers with different levels. We can
define layer level simply as the code’s distance from inputs/outputs—so things like UI, DB or
REST clients are pretty low-level, whereas business logic and models are on the opposite side
and the application logic sits somewhere in the middle.
In this case, it’s a good idea to isolate higher-level layers from external dependencies such as
platform SDK or other invasive frameworks and libraries, since higher levels should be more
stable and independent of the implementation details in lower layers. ArchUnit can help us with
that:
@ArchTest
val `higher-level classes should not depend on the framework` =
noClasses().that().resideInAnyPackage("..presentation..", "..domain..")
.should().dependOnClassesThat().resideInAnyPackage(
"android..",
"androidx..",
"com.google.android..",
"com.google.firebase.."
)
Only a few lines and those pesky imports have no way of creeping in your pristine (and now even
fairly platform-independent!) code.
Piece(s) of cake
Speaking of layers, we should not only handle their dependencies on the 3rd party code, but of
course also the direct dependencies between them. Although we can use the constructs mentioned
above, ArchUnit has another trick up to its sleeve when it comes to layered architectures.
Suppose we have defined these layers and their code dependencies:
This is just an example, but let’s say that the domain layer is the most high-level, so it must
not depend on anything else; presentation and data layers can depend on stuff from domain, UI
can see view models in presentation layer (but view models must not know anything about UI) and
DI sees all to be able to inject anything (and ideally, no other layer should see DI layer,
because classes should not know anything about how they are injected; alas this is not always
technically possible).
Whatever your actual layers are, the most important thing is that all dependencies go in one
direction only, from lower level layers to higher level layers (this is the basic idea of Clean
architecture). ArchUnit can encode these rules in one succinct test:
@ArchTest
val `layers should have correct dependencies between them` =
layeredArchitecture().withOptionalLayers(true)
.layer(DOMAIN).definedBy("..domain")
.layer(PRESENTATION).definedBy("..presentation")
.layer(UI).definedBy("..ui")
.layer(DATA).definedBy("..data")
.layer(DI).definedBy("..di")
.whereLayer(DOMAIN).mayOnlyBeAccessedByLayers(DI, PRESENTATION, DATA)
.whereLayer(PRESENTATION).mayOnlyBeAccessedByLayers(DI, UI)
.whereLayer(UI).mayOnlyBeAccessedByLayers(DI)
.whereLayer(DATA).mayOnlyBeAccessedByLayers(DI)
.whereLayer(DI).mayNotBeAccessedByAnyLayer()
How does it work? layeredArchitecture() is a static method in the
Architectures class (again, please import it). First we need to
actually define our layers: layer declares the layer (the
argument is simply any descriptive String constant) and definedBy specifies a package by which the layer is, well,
defined (you can use package notation which we’ve seen before; you can also use a more general
predicate). Without withOptionalLayers(true)
call, ArchUnit will require that all layers exist, which in a multi-module project might not
necessarily be true (some modules might for example contain only domain stuff).
This rather short test will have an enormous impact on your codebase—correctly managed
dependencies are what prevents your project from becoming a giant mess of spaghetti code.
Inner beauty
We’ve sorted the layers and packages, but what about their content? Take for example the domain
layer: Continuing our rather simplified example, we want only UseCase classes and Repository
interfaces in there. Furthermore, we want for these classes to follow certain name conventions
and to extend correct base classes.
We can express all these requirements by the following set of ArchUnit tests:
@ArchTest
val `domain layer should contain only specified classes` =
classes().that().resideInAPackage("..domain..")
.should().haveSimpleNameEndingWith("UseCase")
.andShould().beTopLevelClasses()
.orShould().haveSimpleNameEndingWith("Repository")
.andShould().beInterfaces()
@ArchTest
val `classes named UseCase should extend correct base class` =
classes().that().haveSimpleNameEndingWith("UseCase")
.should().beAssignableTo(UseCase::class.java)
@ArchTest
val `use case subclasses should have correct name` =
classes().that().areAssignableTo(UseCase::class.java)
.should().haveSimpleNameEndingWith("UseCase")
And as a bonus example for Android fans, you can, of course, be even more specific:
@ArchTest
val `no one should ever name fields like this anymore ;)` =
noFields().should().haveNameMatching("m[A-Z]+.*")
Endless power
We’ve seen only a smart part of the ArchUnit API, but there’s almost nothing that ArchUnit tests
cannot handle. You can examine all Java constructs and their wildest combinations (but always be
aware of Kotlin-Java interoperability details and test your tests), go explore!
Next time, we’ll take a look at some advanced features
and configuration options.