Architecture tests with ArchUnit, pt. 4: Extensions & Kotlin support
12/9/2020
Architecture tests with ArchUnit, pt. 4: Extensions & Kotlin support

​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:

  1. The starting point is the input of our custom transformer, predicate, or condition, which in this case will be ArchUnit’s JavaClass object.
  2. ArchUnit can read annotations on the JavaClass object, so we examine if Kotlin’s @Metadata annotation is present.
  3. 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.)
  4. 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!

Tags

#architecture; #jvm; #tdd; #android; #kotlin

Author

Jiří Hutárek

Versions

ArchUnit 0.14.1
Kotlin 1.3.72
kotlinx-metadata-jvm 0.1.0
KotlinPoet 1.6.0