Let’s start by taking a look at the following code review comments:
“Hey Jane, the indentation is not correct in this line”
“Hey John, I see the ViewModel depends on the Repository directly. It’s best to depend on a UseCase instead.”
“Didn’t we recently agree that UseCases should only expose one function?”
“Wait I’m confused, are we supposed to access String resources directly from ViewModels?”
And of course the most important one:
“There are two blank lines here 😱”
Do you know what are these? These are all code review smells! (and yes, this term does exist, unfortunately I was not the one who coined it)
Engineers are not designed to remember things. But we can definitely build the tools that will do that for us.
Whenever we have alignment on the code style, on the architecture of our project, or in general any best practices that we should follow to ensure a healthy and maintainable codebase, we will need to build an automation that enforces it. And that is a lint rule.
When developers build a new feature, they should feel confident on how to proceed. They should be able to understand each layer of the architecture, the responsibility of each class, and the rules that we have established. And new hires should be able to get onboarded with the codebase as quickly as possible without feeling lost.
Because there will be a well-oiled process in place that enforces those standards and doesn’t leave much room for debate. And this process of course will be continuously evolving over time as we discover new practices and align on them with our teams.
Keeping the codebase consistent will boost developers’ productivity, will make code reviews faster, and will add a foundational layer in protecting our apps from bugs.
Why Konsist? Don’t we already have linters?
It’s true, we’ve had linters for years! Even Android Studio comes with a built-in one. But after trying a few alternatives in our Android apps at PSS, we realized one thing: They all had a steep learning curve and none of them could enforce architectural rules in a maintainable way, so we had to be extra vigilant in detecting those violations during code reviews. The code was hard to read, the integration with our CI/CD system was not a trivial task, and ultimately, nobody really wanted to write custom lint rules.
Thankfully, we finally landed on Konsist. It is a fairly new project with one main principle: Lint rules are written in the form of unit tests. And that is what’s making the big difference. Since most developers are already familiar with writing unit tests, they will quickly become familiar with writing lint rules as well.
Note: If you happen to struggle with writing unit tests, make sure to check our comprehensive Unit Testing Diet.
Writing lint rules with Konsist
Since lint rules in Konsist are unit tests, we can write them similarly to writing tests using JUnit 5, JUnit 4, or Kotest.
In our projects, we prefer to structure our tests in the Given-When-Then style, so we’ll do the same for our lint rules, using the Behavior Spec of Kotest:
- Given: This is the setup of our test. We will specify the files or classes that our lint rule should apply to.
- When: In unit tests, this is normally the action that is being performed before we evaluate the output. Since lint rules are not associated with a specific action, this step is optional. We can still use it though for any additional filtering on the files that we specified in the previous step (e.g. filtering the properties of a class, constructors, functions, etc.).
- Then: This is our assertion. We will assert that the files or classes that we specified in the previous steps do not violate the lint rule that we want to enforce.
Here is an example of a lint rule that enforces all ViewModels to extend our BaseViewModel
:
dependencies {
testImplementation("com.lemonappdev:konsist:$konsistVersion")
testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
}
class ViewModelsExtendBaseViewModel : BehaviorSpec() {
init {
Given("All classes in production code") {
val scope = Konsist.scopeFromProduction().classes()
When("There is a ViewModel") {
val viewModels = scope.withNameEndingWith("ViewModel")
Then("It extends the BaseViewModel") {
viewModels.assertTrue {
it.hasParentWithName("BaseViewModel")
}
}
}
}
}
}
And here’s a breakdown of what that code is doing:
- Given: We first define the scope, which represents the set of files or classes that we want to target. In this case, we include all classes in the production code (excluding the test classes). We can also target specific modules, source sets, packages, or directories. For all available options, you can take a look at the documentation.
- When: We filter those classes to get only the ones with names ending with
ViewModel
. - Then: We assert that these filtered classes are extending the
BaseViewModel
.
And that was it! Pretty straightforward, right? We now have a lint rule which we can run the same way as we would run a unit test using Gradle or Android Studio directly. And the best part is that we can even follow Test-Driven Development. Once we identify a code smell, we can write a failing test, then fix the violation, and confirm that the test (lint rule) is passing.
Adding a custom message
To make the error message of the lint rule more developer-friendly, we recommend adding a custom message that explains why this lint rule should be enforced, using the additionalMessage
parameter of the assertTrue
function:
/* ... */
viewModels.assertTrue(additionalMessage = MESSAGE) {
it.hasParentWithName("BaseViewModel")
}
/* ... */
private companion object {
private const val MESSAGE =
"Always extend the BaseViewModel when creating a new ViewModel, to take advantage of the lifecycle events and other features it provides."
}
Specifying the baseline
Let’s try running the test (lint rule) that we just wrote:
- Given: We first define the scope, which represents the set of files or classes that we want to target. In this case, we include all classes in the production code (excluding the test classes). We can also target specific modules, source sets, packages, or directories. For all available options, you can take a look at the documentation.
- When: We filter those classes to get only the ones with names ending with
ViewModel
. - Then: We assert that these filtered classes are extending the
BaseViewModel
.
And that was it! Pretty straightforward, right? We now have a lint rule which we can run the same way as we would run a unit test using Gradle or Android Studio directly. And the best part is that we can even follow Test-Driven Development. Once we identify a code smell, we can write a failing test, then fix the violation, and confirm that the test (lint rule) is passing.
Adding a custom message
To make the error message of the lint rule more developer-friendly, we recommend adding a custom message that explains why this lint rule should be enforced, using the additionalMessage
parameter of the assertTrue
function:
/* ... */
viewModels.assertTrue(additionalMessage = MESSAGE) {
it.hasParentWithName("BaseViewModel")
}
/* ... */
private companion object {
private const val MESSAGE =
"Always extend the BaseViewModel when creating a new ViewModel, to take advantage of the lifecycle events and other features it provides."
}
Specifying the baseline
Let’s try running the test (lint rule) that we just wrote:
Oops, it failed! Apparently we already have a few violations of this lint rule in our project. Which means that we have two options:
- Either fix the violations by refactoring our ViewModels to extend the
BaseViewModel
. - Or add the violating classes to the baseline. The baseline is a list of files or classes that we intentionally exclude from this lint rule. This could be either legacy code that we don’t want to refactor at this time or valid cases where the lint rule does not apply.
To apply a baseline, we can create a BASELINE
array in the companion object
and set it as a parameter in the withoutName
function, so that we filter out those classes. In our case, we will add the BaseViewModel
in the baseline, since obviously it cannot extend itself:
/* ... */
val viewModels = scope.withNameEndingWith("ViewModel").withoutName(*BASELINE)
/* ... */
private companion object {
private const val MESSAGE =
"Always extend the BaseViewModel when creating a new ViewModel, to take advantage of the lifecycle events and other features it provides."
private val BASELINE = arrayOf("BaseViewModel")
}
An alternative way for excluding classes from a lint rule is using the @Suppress
annotation in each file or class that we want to exclude, however we recommend using the BASELINE
array directly in the test instead, since it will make the lint rule more self-contained and will allow us to see at a glance which are the exceptions of that rule.
Note: As a best practice, you should try to gradually decrease the number of classes that are included in the baseline as you’re working on technical debt. It is not a good sign if that list keeps on growing.
Benefit #1: Enforcing the architecture
Code review comments on architecture are among the most important but also most disruptive ones. It can take hours for an individual to redesign the architecture, not to mention that such comments often receive the common pushback “Can we fix this later?” (and then never get done).
This is where Konsist shines the most. In its ability to enforce our architectural rules, particularly in a multi-module project. Let’s take a look at a few examples:
- ViewModels should not inject Repositories in their constructor. This ensures that ViewModels will communicate only with the UseCase layer to send or retrieve data:
class ViewModelsDoNotInjectRepositories : BehaviorSpec() {
init {
Given("All classes in production code") {
val scope = Konsist.scopeFromProduction().classes()
When("There is a ViewModel") {
val viewModels = scope.withNameEndingWith("ViewModel")
Then("No Repository is listed in the constructor parameters") {
viewModels.withConstructor {
it.hasParameter { param ->
param.type.name.endsWith("Repository")
}
}.assertEmpty()
}
}
}
}
}
- Repositories should reside in the
repositories
module. This enforces a clean separation of repositories from the rest of the layers of our architecture:
class RepositoriesResideInRepositoriesModule : BehaviorSpec() {
init {
Given("All classes in production code") {
val scope = Konsist.scopeFromProduction().classes()
When("There is a Repository") {
val repositories = scope.withNameEndingWith("Repository")
Then("It resides in the repositories module") {
repositories.assertTrue {
it.resideInModule("data/repositories")
}
}
}
}
}
}
- The domain layer module should not import DTOs. This decouples the domain layer from the application layer, which comes in accordance with Clean Architecture and Domain-Driven Design.
class DomainLayerDoesNotImportDTOs : BehaviorSpec() {
init {
Given("The domain layer module") {
val scope = Konsist.scopeFromDirectory("domain").files
Then("It does not import DTOs") {
scope.assertFalse {
it.text.contains("com.perrystreet.dto")
}
}
}
}
}
- The design system should not import domain models. This ensures that the design system is independent and agnostic of the features of our app:
class DesignSystemDoesNotImportDomainModels : BehaviorSpec() {
init {
Given("The design system module") {
val scope = Konsist.scopeFromDirectory("design-system").files
Then("It does not import domain models") {
scope.assertFalse {
it.text.contains("com.perrystreet.domain.models")
}
}
}
}
}
Job Offers
Benefit #2: Preventing bugs
Normally, whenever we encounter a bug in our codebase, we need to ask ourselves the following question:
“Can we write a unit test to prevent this bug?”
You know what’s even better though than a unit test? A lint rule! Because the lint rule can prevent the bug at an even earlier stage of development. So, the first question we’ll need to ask should be rephrased:
“Can we write a lint rule to prevent this bug?”
The well-known concept of the Test Pyramid showcases the importance of having more low-level tests that are easier to write and faster to run, to ensure that the code is behaving as expected and to protect it from bugs. We believe though that an additional layer should be added at the bottom of the pyramid to highlight the importance of preventing bugs at an even lower level: the lint rules layer:
Note: On the debatable topic of whether we should have more unit tests or more integration tests, take a look at the analysis we have previously done.
Let’s take a look at a lint rule that protects us from a bug that we actually recently encountered in our projects.
Consider the following function in Retrofit, which makes a POST request to the server, sending the id
and name
as @Field
parameters:
@POST(PATH)
fun postProfile(@Field("id") id: Long, @Field("name") name: String)
However, once you try executing it, the app will crash with the following error:
java.lang.IllegalArgumentException: @Field parameters can only be used with form encoding. (parameter #1)
for method postProfile
The @Field
annotation sends the data as form-URL-encoded, and it requires the @FormUrlEncoded
annotation in the function, otherwise it’ll crash:
@POST(PATH)
@FormUrlEncoded
fun postProfile(@Field("id") id: Long, @Field("name") name: String)
What a perfect use case for a lint rule to prevent this issue!
class RetrofitFieldParamsUseFormUrlEncoded : BehaviorSpec() {
init {
Given("All functions in production code") {
val scope = Konsist.scopeFromProduction().functions()
When("There is a function with the @POST annotation") {
val functions = scope.withAnnotationNamed("POST")
And("It has at least one @Field parameter") {
val functionsWithFieldParams = functions.withParameter {
it.hasAnnotationWithName("Field")
}
Then("It has the @FormUrlEncoded annotation") {
functionsWithFieldParams.assertTrue {
it.hasAnnotationWithName("FormUrlEncoded")
}
}
}
}
}
}
}
And here’s a breakdown of the code:
- Given: We take all functions in the production code.
- When: We filter them to get the ones that have the
@POST
annotation. - And: We apply an additional filter to get the functions that have at least one parameter with the
@Field
annotation. - Then: We assert that these filtered functions also have the
@FormUrlEncoded
annotation.
Project structure with Konsist
As we saw previously, Konsist lint rules are just unit tests. Which means that in order to run them, you’ll need to place them in the test
source set of your project, alongside the rest of your unit tests.
However, there’s an even better approach, particularly if you’re working on a multi-module project. To decouple the lint rules from the rest of the unit tests, you can create a dedicated module that will be the place for all your lint rules:
And this is how the module structure and the build.gradle.kts
file will look like:
plugins {
id("kotlin")
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.test {
useJUnitPlatform()
}
dependencies {
testImplementation("com.lemonappdev:konsist:$konsistVersion")
testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
}
Note: You do not need to add the rest of your modules as dependencies here. Konsist by default will have access to the entire project and it doesn’t require any explicit dependencies.
Another benefit that we get with this approach is we can run our lint rules separately from the rest of the unit tests using the :{module}:test
Gradle task:
./gradlew :konsist:test
Lastly, to reduce duplicate code when writing lint rules, we recommend creating a KonsistUtils.kt
file with the scopes that you commonly use:
object KonsistUtils {
val productionCode
get() = Konsist.scopeFromProduction()
val classes
get() = productionCode.classes()
val interfaces
get() = productionCode.interfaces()
val viewModels
get() = classes.withNameEndingWith("ViewModel")
val useCases
get() = classes.withNameEndingWith("UseCase")
val repositories
get() = classes.withNameEndingWith("Repository")
val domainModule
get() = Konsist.scopeFromDirectory("domain")
val designSystemModule
get() = Konsist.scopeFromDirectory("design-system")
/* ... */
}
Running the lint rules on CI/CD
So, after all the hard work of setting up our Konsist module and writing our custom lint rules, there is one thing left: How do we enforce them in our CI/CD system to block a pull request from being merged if a lint rule is violated?
If you’ve already set up a pipeline for your unit tests, then this will be just as easy. The only thing needed is an additional step that will run the unit tests in the konsist
module we previously created.
Let’s take a look at an example configuration using GitHub Actions or Bitrise:
GitHub Action configuration
The following GitHub Action checks out the repository, sets up Java and Gradle, and then executes the Gradle task that runs all unit tests (lint rules) in the konsist
module. We trigger it when a pull request is opened:
name: Run Konsist lint rules
on:
pull_request:
types: [opened, synchronize]
jobs:
run-lint-rules:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
- name: Run Konsist lint rules Gradle task
run: ./gradlew :konsist:test
Once the GitHub Action is set up, you can make it a required status check in the branch protection rules in your repository settings, so that it will block pull requests from being merged if they violate any lint rule.
Bitrise configuration
If you’re using Bitrise, you will just need to add one extra step in your workflow that runs the :konsist:test
Gradle task:
Finally, if you want to run all unit tests across all modules in parallel, including the Konsist lint rules, you can combine them in a single Gradle task which you can define in the top-level build.gradle.kts
file:
tasks.register("runAllTests") {
dependsOn(
":app:test",
":domain:test",
":other-module:test",
// ...
":konsist:test"
)
}
Summary
Lint rules act as written agreements for the architecture of a codebase and the established best and necessary practices of a team. They help keep the codebase consistent by eliminating ambiguities, they protect our projects from subtle bugs, and they can significantly speed up writing and reviewing code.
With Konsist, we finally have a long-overdue tool on Android that makes writing lint rules straightforward and accessible without a steep learning curve. A tool that certainly marks a milestone for writing custom lint rules, with its genius idea of reimagining them as unit tests.
Please make sure to review the Konsist contribution guidelines to learn how you can support the project.
PS. What about iOS?
Our team has been using SwiftLint on iOS, a tool powerful in its ability to write regexp-based rules using filename pattern matching, which allows for most, but not all, architectural rules to be written. We hope to write more about this in an upcoming blog post.
About the author
Stelios Frantzeskakis is a Staff Engineer at Perry Street Software, publisher of the LGBTQ+ dating apps SCRUFF and Jack’d, with more than 30M members worldwide.
This article is previously published on proandroiddev.com