ArchUnit vs Konsist in Android (Kotlin-oriented) codebase. A comparison of hidden implementation checks.
The background
To get on with Konsist please check following article https://medium.com/kotlin-academy/introducing-konsist-a-cutting-edge-kotlin-linter-d3ab916a5461.
To see what are the main differences between Konsist and ArchUnit please check another great article https://medium.com/proandroiddev/archunit-vs-konsist-why-did-we-need-another-linter-972c4ff2622d.
Both articles are from Igor Wojda 🤖. I recommend to read it! :)
The story behind!
Let me start with a short story behind this article.
At the company where I’m working I had a chance to implement set of ArchUnit rules with my dear teammate Łukasz Tokarski (https://www.linkedin.com/in/itcomposition/). Every X sprints we have so called innovation sprints when we can try to implement/check some newest stuff from Android/Kotlin world or try to solve some amazing problems of the code. I’ve seen some articles about Konsist from Igor Wojda 🤖 and it struck me how simple it is in comparison to what we have implemented and I thought this might be a great candidate.
The domain of the article
Based on a defined rule I will try to illustrate how simple migration to Konsist from ArchUnit can be in Android repository.
At the very beginning let’s define a rule that we check with ArchUnit and we would like to migrate to Konsist.
Rule to check:
Let’s assume our internal coding standard assumes that all classes implementing other interfaces, abstract classes should have Impl
postfix in name and be internal
. We also have some classes awaiting for code refactor and we would like to suppress them temporarily so for that we’re using annotation presented below:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
annotation class SuppressHiddenImplementationCheck(val reason: String = "Requires refactor")
ArchUnit:
Setup: https://www.archunit.org/getting-started
Simply saying ArchUnit is powerful framework for architecture testing, but it lacks simple checks for Kotlin classes. To implement these checks, we need to add custom predicates and import options to our codebase.
First, we create a predicate to check if a Kotlin class is annotated with specific Annotation:
fun classAnnotatedWith(annotation: Annotation) = object : DescribedPredicate<JavaClass>("Kotlin class Annotated") {
override fun test(clazz: JavaClass?): Boolean {
return clazz?.annotations?.any { javaAnnotation ->
javaAnnotation.type.name == annotation.annotationClass.qualifiedName
} ?: false
}
}
Next, we create a predicate to check if a class name ends with Impl
:
fun classNameEndsWith(postFix: String) = object : DescribedPredicate<JavaClass>("Kotlin Simple Class Name") {
override fun test(clazz: JavaClass?): Boolean {
return clazz?.name?.endsWith(postFix) ?: false
}
}
Finally, we define a condition to verify if classes have the internal
modifier:
val kotlinInternalCheckCondition = object : ArchCondition<JavaClass>("Internal modifier") {
override fun check(clazz: JavaClass?, events: ConditionEvents?) {
if (clazz?.reflect()?.isKotlinInternal() == true) {
events?.add(SimpleConditionEvent.satisfied("item", "$clazz has internal modifier"))
} else {
events?.add(SimpleConditionEvent.violated("item", "$clazz has not internal modifier"))
}
}
}
We combine these rules and verify the implementation:
fun verifyImplementationIsHidden(javaClasses: JavaClasses) {
val classesToVerify = javaClasses.that(
DescribedPredicate
// We don't want to check Suppressed classes
.doesNot(classAnnotatedWith(SuppressHiddenImplementationCheck()))
// Class should have `Impl` at the end of the name
.and(classNameEndsWith("Impl"))
)
ArchRuleDefinition.classes()
.should(kotlinInternalCheckCondition)
.check(classesToVerify)
}
In case you’re an Android developer then probably you would like to exclude Android generated files and ArchUnitTests from verification, so we need to have custom ImportOptions for ArchUnit defined.
class DoNotIncludeAndroidGenerateFiles : ImportOption {
private val pattern = Pattern.compile(".*R(\\$\\w+)?.class|.*BuildConfig.*")
override fun includes(location: Location?): Boolean {
return location?.matches(pattern)?.not() ?: true
}
}
class DoNotIncludeArchUnitTestClasses : ImportOption {
private val pattern = Pattern.compile(".*/build/tmp/kotlin-classes/(your-build-flavors)(Release|Debug)UnitTest/.*")
override fun includes(location: Location?): Boolean {
return location?.matches(pattern)?.not() ?: true
}
}
We can then write the test to run:
@RunWith(ArchUnitRunner::class)
@AnalyzeClasses(
packages = ["com.your.module.package"],
importOptions = [
ImportOption.DoNotIncludeTests::class,
DoNotIncludeAndroidGenerateFiles::class,
DoNotIncludeArchUnitTestClasses::class,
// Some other import options you would need
],
cacheMode = CacheMode.PER_CLASS
)
internal class GeneralArchUnitTests {
@ArchTest
fun `implementation should be hidden`(javaClasses: JavaClasses) {
verifyImplementationIsHidden(javaClasses)
}
}
Konsist:
Setup: https://github.com/LemonAppDev/konsist#dependencies
Konsist is a new framework for architecture testing and it is designed for Kotlin. It offers a set of well-designed APIs, even in its early stages.
Here’s a test for our defined rule in Konsist:
class KonsistGeneralTest {
@Test
fun `all classes with 'Impl' postfix should be internal`() {
Konsist.scopeFromProduction()
.classes()
.withoutAnnotationOf(SuppressHiddenImplementationCheck::class)
.withNameEndingWith("Impl")
.assert { it.hasInternalModifier }
}
}
Summary:
To meet our expectations using ArchUnit we needed to add in general 58 lines of code where in Konsist it’s just 10.
The above example perfectly illustrates complexity of tests written in ArchUnit, especially when we would like to adapt it to Kotlin. In contrast, Konsist simplifies the process significantly, making it an appealing choice.
Additionally, it’s worth considering that the entry barrier to the ArchUnit framework is much higher when right from the start we need to write custom rules to adapt it to our codebase.