Architecture tests with ArchUnit, pt. 1: Reasons
9/8/2020
Architecture tests with ArchUnit, pt. 1: Reasons

Good architecture is essential for a codebase to enjoy a long and happy life (the other crucial ingredient is running automated unit tests, but that’s a different blog post). Nowadays there are many sensible options, including our favorite for mobile apps, Clean arch, but no matter which one you choose, you’ll need to document and enforce it somehow.

The former is traditionally accomplished by some form of oral history passed from one developer to another (sometimes augmented by blurry photos of frantically scribbled whiteboard diagrams), while the latter is sporadically checked during code reviews (if there’s time—which there isn’t—and you remember all the rules yourself—which you don’t).

Or maybe you even have a set of gradually outdated wiki pages and fancy UML models created in some expensive enterprise tool. Or complicated but incomplete rules written for rather arcane static analysis frameworks. Or any other form of checks and docs, which are usually hard to change, detached from the actual code and difficult and rather expensive to maintain.

But fret not! There is a new architectural sheriff in this JVM town of ours and he’s going to take care of all of this—say hello to your new best friend, ArchUnit!

What’s all the fuss about?

ArchUnit is a library to, well, unit test your architecture. There are other tools to check your architecture, but the “unit testing” part of ArchUnit is actually its killer feature.

While “normal” unit tests should describe behavior (not structure!) of the system under test, ArchUnit cleverly leverages JVM and existing unit test frameworks to let you document and check your architecture in a form of runnable unit tests, executable in your current unit test environment (because you already have a strong suite of unit tests, right?). Why exactly is this such a welcome improvement?

Well, it all boils down to the fundamental benefits of all unit tests: Because unit tests are code, they are a precise, up-to-date, unambiguous, executable specification of the system. Docs can be outdated and misleading, but unit tests either compile or don’t; they either pass or not. Imagine opening a project you don’t know anything about, running its unit tests and seeing this:

ArchUnit test results in Android Studio

Suddenly the whole onboarding situation looks much brighter, doesn’t it?

Show me the code

Enough talk, let’s get down to business! If your test framework of choice is JUnit 4, put this in your build.gradle.kts:

dependencies {
    testImplementation("com.tngtech.archunit:archunit-junit4:0.14.1")
}

There are artifacts for other test frameworks as well, just refer to the docs. Be careful not to use older versions as this version contains important fixes for multi-module projects containing Android libraries in a CI environment.

Now we can write our first architecture test:

@RunWith(ArchUnitRunner::class)
@AnalyzeClasses(packages = ["com.example.myapp"])
internal class UiLayerTest {
    @ArchTest
    val `view model subclasses should have correct name` =
        classes().that().areAssignableTo(ViewModel::class.java)
            .should().haveSimpleNameEndingWith("ViewModel")
}

And just like that, you now have one small naming convention documented and automatically verified across your whole project. The API does a great job at being self-explanatory and we’ll get into the details later, but let’s quickly recap what we have here:

@AnalyzeClasses annotation is one of the ways to specify what to check. Here, we simply want to test all code in the com.example.myapp package and its subpackages. ArchUnit imports and checks Java bytecode (not source files), which is why it works with Kotlin (or any other JVM language), although it’s itself a pure Java library—another example of Kotlin’s stellar interoperability with Java. Where ArchUnit actually gets this bytecode is a slightly more complicated question, but that’s not important right now.

Anyway, we annotate our test cases with @ArchTest and for the shortest syntax, we use properties instead of functions. As with other unit tests, it’s a good idea to leverage Kotlin’s escaped property names for more readable test outputs.

And then finally for the main course: ArchUnit has a comprehensive, very expressive and really rather beautiful fluent API for specifying the predicates and their expected outcomes. It’s not Java reflection and being a pure Java library, ArchUnit doesn’t have constructs for Kotlin-exclusive language elements, but it’s still more than powerful enough.

Test the tests

Now run the test. Most projects probably stick to this naming convention, so the result bar in your favorite IDE might be green already. But wait! How do we know that the tests actually work?

Although they may appear a bit strange, ArchUnit tests are still unit tests and we should treat them as such. That means we should follow the famous red-green-refactor cycle, albeit modified, because you absolutely need to see the test fail and it must fail for the correct reason. This is the only time when you actually test your tests!

What does this mean for ArchUnit tests? The difference from normal TDD for our specific test case is that we cannot simply write the test first and watch it fail, because if there are no view models in the project yet, the test will pass. So we need to cheat a little and break the architecture on purpose, manually, by creating a temporary class violating the naming convention in the main source set. Then we run the test, watch it fail, delete the class and watch the test go green (the refactoring part isn’t really applicable here).

This looks like extra work and granted, it can be a bit tedious, but the red part of the cycle simply cannot be skipped, ever. There is a myriad of logical and technical errors that can result in the test being wrong or not executed at all and this is your only chance to catch them. There’s nothing worse than a dead lump of code giving you a false sense of security.

And there’s one more thing to borrow from TDD playbook: Perhaps you are doing a code review or approving pull request and you discover some construction violating a rule that you haven’t thought of before. What to do with that? As with all new bugs, don’t rush fixing the bug! The first thing you should do is write a test exposing the bug (the red part of the cycle)—that means writing an ArchUnit rule which will fail with the offending code. Only after that, make the test green. This way, you’ll slowly make your test suite more precise, with the added bonus that future regressions will be prevented as well.

Be careful what you test for

We’ll take a look at all ArchUnit’s fluent API constructs in a future post, but there’s an important detail we need to discuss before that.

Basically all simple ArchUnit rules follow the form (no) LANGUAGE_ELEMENT that PREDICATE should (not) CONDITION. From a mathematical point of view, these rules are implications.

An implication looks like this:

Venn diagram of an implication

For our example test above (and many other tests that you’ll write), it means that the test will pass for all these variants:

  • class is not assignable to ViewModel::class and does not have a simple name ending with ViewModel (that’s OK)
  • class is assignable to ViewModel::class and has a simple name ending with ViewModel (that’s also OK)
  • class is not assignable to ViewModel::class and has a simple name ending with ViewModel (the criss-crossed part of the diagram; we don’t really want to allow this)

It seems that what we really want is an equivalence:

Venn diagram of an equivalence

Although ArchUnit doesn’t (yet?) have API elements to specify equivalences, they are fairly simple to create: Because A ↔ B is the same as (A → B) AND (B → A), we just need to add another test to our suite:

@ArchTest
val `classes named ViewModel should have correct super class` =
    classes().that().haveSimpleNameEndingWith("ViewModel")
       .should().beAssignableTo(ViewModel::class.java)

This way, the offending case which the first test didn’t catch (class name ends with ViewModel, but it is not assignable to ViewModel.java) is prevented.

Best thing since sliced bread

I don’t want to use the word game-changer, but I just did. Since we started adding ArchUnit tests to our projects, we have seen significant improvements in developer productivity and the health of our codebases. Compared to similar solutions, ArchUnit’s simple integration, ease of use and expressive powers are unmatched.

We’ve only scratched the surface of what’s possible, so next time, we’ll dive into ArchUnit APIs to discover some nifty architecture testing goodness!

Tags

#architecture; #jvm; #tdd; #android

Author

Jiří Hutárek

Versions

ArchUnit 0.14.1
Kotlin 1.3.72