ArchUnit is immensely capable on its own and that's a great merit on its own, but it doesn’t stop there—ArchUnit’s power can be augmented by adding
custom matchers, language elements, and even whole new concepts. In this post, we’ll look at how
we can achieve that and then we’ll see if we can leverage these capabilities to support even
Kotlin-exclusive language elements in ArchUnit tests (spoiler alert: yes, we can!). Ready?
Shiny new things
As we’ve mentioned several times, ArchUnit rules look like this:
(no) LANGUAGE_ELEMENT that PREDICATE should (not) CONDITION
As it turns out, by thoroughly following the open/closed principle, ArchUnit allows us to supply our
own language elements, predicates and conditions. These can be simple aggregations of existing
built-in predicates or conditions to facilitate reuse, or we can invent entirely new
domain-specific concepts to utilize in our architecture tests. So how is it done?
To create a custom language element, predicate or condition, we need to extend
AbstractClassesTransformer, DescribedPredicate,
or ArchCondition respectively. Each abstract base class takes
one type argument—the language element it operates on (ArchUnit provides for example JavaClass, JavaMember, JavaField or JavaCodeUnit and
we can even create our own; these are reflection-like models read from compiled bytecode). They
also have one constructor argument, a String that is appended to the rule description in error
messages.
The simplest one to implement is DescribedPredicate—we need to
override its apply method which takes the examined language
element and returns Boolean:
val myPredicate = object : DescribedPredicate("rule description") {
override fun apply(input: JavaClass): Boolean = // …
}
ArchCondition is slightly more involved, as its check function takes the language element as well. In addition, it also takes ConditionEvents collection, which is used to
return the result of the evaluation, as this function doesn’t directly return anything:
val myCondition = object : ArchCondition("condition description") {
override fun check(item: JavaClass, events: ConditionEvents) {
if (item.doesNotSatisfyMyCondition()) {
events.add(SimpleConditionEvent.violated(item, "violation description"))
}
}
}
AbstractClassesTransformer has a doTransform
method which takes a collection of JavaClasses and
transforms it to another collection. Elements of the output collection can be JavaClasses as well, different built-in types or even custom
classes. The transformation may comprise any number of operations including mapping or
filtering:
val myTransformer = object : AbstractClassesTransformer("items description") {
override fun doTransform(collection: JavaClasses): Iterable =
collection.filter { /* ... */ }.map { /* ... */ }
}
Anyway, we can use our custom transformers, predicates, and conditions like this:
all(encryptedRepositories)
.that(handleGdprData)
.should(useSufficientlyStrongCrypthographicAlgorithms)
and they can, of course, be combined with the built-in ones.
Besides promoting reuse, custom transformers, predicates, and conditions are particularly good at
increasing the level of abstraction of your tests—it’s better to describe your system using its
domain language instead of low-level, opaque technical terms.
Gimme some Kotlin lovin’
As promised, now it’s the time to tackle the last thing we’d like to have in ArchUnit
tests—Kotlin support.
Because ArchUnit reads compiled bytecode and Kotlin has killer Java interoperability, we can get
pretty far out of the box, but we still can’t directly test for Kotlin stuff like sealed and
data classes, objects, typealiases, suspending functions etc. To find out how that could be
possible, we need to take a slight detour first.
When targeting JVM (or Android), Kotlin compiler outputs JVM bytecode. Adding custom bytecodes
for Kotlin-exclusive constructs is of course out of the question, so the compiler must resort to
clever tricks to convert Kotlin stuff to vanilla JVM bytecode. Now, there’s some wiggle room
(JVM bytecode allows some things that Java as a language doesn’t), but still, to achieve
Kotlin’s stellar level of Java interoperability, the compiler must mostly play by Java’s
rules.
To achieve that, for example, Kotlin compiler generates getters, setters and backing fields for
properties. It also creates encapsulating classes for top level functions and properties, adds new
functions to existing classes (to support data classes) or adds parameters to existing functions
(this is one of the tricks behind suspending functions).
As a result, when examining the bytecode alone, those Kotlin concepts effectively disappear. But
for Kotlin compiler to be able to compile Kotlin code against another already compiled
Kotlin code (and to see it as Kotlin code, not generic JVM code), this information must be
preserved somewhere.
Take for example this simple data class:
data class Money(val amount: BigDecimal, val currency: Currency)
IntelliJ Idea/Android Studio lets us see bytecode generated from this Kotlin code, which in turn
can be (in most cases) decompiled to equivalent Java code. If we do that with the Money class, we’ll see something like this:
@Metadata(
mv = {1, 1, 16},
bv = {1, 0, 3},
k = 1,
d1 = {"\u0000,\n\u0002\u0018\u0002\n\ /* rest omitted */ },
d2 = {"Lcom/example/myapp/Money;", "", "amount", "Ljava/math/BigDecimal;", "currency", "Ljava/util/Currency;", "(Ljava/math/BigDecimal;Ljava/util/Currency;)V", "getAmount", "()Ljava/math/BigDecimal;", "getCurrency", "()Ljava/util/Currency;", "component1", "component2", "copy", "equals", "", "other", "hashCode", "", "toString", "", "app-main"}
)
public final class Money {
@NotNull private final BigDecimal amount;
@NotNull private final Currency currency;
@NotNull public final BigDecimal getAmount() { return this.amount; }
@NotNull public final Currency getCurrency() { return this.currency; }
/* rest omitted */
Bingo! The Java part is pretty straightforward, but it looks like that strange @Metadata stuff
might be what we need. Indeed, the documentation for @Metadata
says that “This annotation is present on any class file produced by the Kotlin compiler and is
read by the compiler and reflection.” Its arguments contain various interesting Kotlin-exclusive
bits and pieces related to the class and because it has runtime retention, it will be stored in
binary files, which means we can read them from our ArchUnit tests! If only we could make sense
of that gibberish inside the annotation…
Metadata dissection
It turns out that we can! There’s a small official library to do just that.
First, add JVM metadata library to your build script:
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.1.0")
}
Then, our plan of attack is this:
- The starting point is the input of our custom transformer, predicate, or condition, which in
this case will be ArchUnit’s JavaClass object.
- ArchUnit can read annotations on the JavaClass object, so we
examine if Kotlin’s @Metadata annotation is present.
- If it is, we use the kotlinx-metadata
library to read the actual metadata. (KotlinPoet has a higher-level API
based on
kotlinx-metadata, which presumably might be a little bit nicer to use; we’ll just use the
basic API here, as the end result will be the same in either case.)
- We expose the data in some easily digestible object so we can write simple and readable
assertions about it.
To make an already long story short, here is the first piece of the puzzle—transformation from
ArchUnit’s JavaClass to kotlinx-metadata KmClass model:
private fun JavaClass.toKmClass(): KmClass? = this
.takeIf { it.isAnnotatedWith(Metadata::class.java) }
?.getAnnotationOfType(Metadata::class.java)
?.let { metadata ->
KotlinClassMetadata.read(
KotlinClassHeader(
kind = metadata.kind,
metadataVersion = metadata.metadataVersion,
bytecodeVersion = metadata.bytecodeVersion,
data1 = metadata.data1,
data2 = metadata.data2,
extraString = metadata.extraString,
packageName = metadata.packageName,
extraInt = metadata.extraInt
)
)
}
?.let { (it as? KotlinClassMetadata.Class)?.toKmClass() }
If a given JavaClass is annotated with @Metadata,
this extension reads the annotation and
converts it to a KotlinClassMetadata object (mapping the
annotation attributes to the
corresponding properties of KotlinClassHeader along the way).
KotlinClassMetadata is a sealed class and its subclasses
represent various different kinds of classes generated by the Kotlin compiler. There are a few
of them, but to keep things simple we are interested only in “real” classes
(KotlinClassMetadata.Class) from which we finally extract the
rich KmClass model (and return
null in all other cases).
To make our life easier later, we also add this handy extension:
private fun JavaClass.isKotlinClassAndSatisfies(predicate: (KmClass) -> Boolean): Boolean =
this.toKmClass()?.let { predicate(it) } == true
Grand finale
Now we can finally write our transformers, predicates, and conditions. Because they will be all
quite similar, let’s create factory methods for them first:
fun kotlinClassesTransformer(description: String, predicate: (KmClass) -> Boolean) =
object : AbstractClassesTransformer(description) {
override fun doTransform(collection: JavaClasses): Iterable =
collection.filter { it.isKotlinClassAndSatisfies(predicate) }
}
fun kotlinDescribedPredicate(description: String, predicate: (KmClass) -> Boolean) =
object : DescribedPredicate(description) {
override fun apply(javaClass: JavaClass) =
javaClass.isKotlinClassAndSatisfies(predicate)
}
fun kotlinArchCondition(
ruleDescription: String,
violationDescription: String,
predicate: (KmClass) -> Boolean
) = object : ArchCondition(ruleDescription) {
override fun check(javaClass: JavaClass, events: ConditionEvents) {
if (!javaClass.isKotlinClassAndSatisfies(predicate)) {
events.add(SimpleConditionEvent.violated(javaClass, "$javaClass $violationDescription"))
}
}
}
And now, finally, we have everything ready to write things we can actually use in our ArchUnit
tests—for example:
val kotlinSealedClasses = kotlinClassesTransformer("Kotlin sealed classes") {
it.sealedSubclasses.isNotEmpty()
}
val areKotlinDataClasses = kotlinDescribedPredicate("are Kotlin data classes") {
Flag.Class.IS_DATA(it.flags)
}
val beKotlinObjects = kotlinArchCondition("be Kotlin objects", "is not Kotlin object") {
Flag.Class.IS_OBJECT(it.flags)
}
The predicate lambdas operate on KmClass instances. KmClass is quite a low-level but powerful API
to examine @Metadata annotation content. KmClass has direct methods or properties for some
Kotlin constructs, while others can be derived from its flags. Sometimes it takes a little bit
of exploration, but all Kotlin-specific stuff is there. Or, for a higher-level API to do the
same, see KotlinPoet
metadata.
Now we can write tests such as:
all(kotlinSealedClasses)
.that(resideInAPackage("..presentation"))
.should(haveSimpleNameEndingWith("State"))
classes()
.that(resideInAPackage("..model")).and(areKotlinDataClasses)
.should(implement(Entity::class.java))
classes()
.that().areTopLevelClasses().and().areAssignableTo(UseCase::class.java)
.should(beKotlinObjects)
So there you have it—support for Kotlin constructs in ArchUnit. The sky’s the limit, now your
codebase can be more robust than ever!
Conclusion
Well, this was quite a journey! The benefits of having even a small suite of ArchUnit tests in
your project are immense—they prevent subtle, hard-to-catch bugs, act as the best possible
documentation of your architecture, save you time during code reviews and keep your codebase
clean, consistent, maintainable and healthy. They are easy to write, simple to integrate into
your CI/CD pipeline and extendable even beyond the original language. What’s not to like? Start
writing them now and reap the rewards for years to come!