Blog Infos
Author
Published
Topics
, , , ,
Published
When reviewing code that has the view, the business logic, and network calls, all in a single class. Oh and it’s also using an AsyncTask. Photo by skynesher on iStock.

 

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.

Typical code for a custom lint rule using existing linters — if you’ve tried writing them before, you will relate. Photo by maciek905 on iStock.

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 5JUnit 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:

  1. Given: This is the setup of our test. We will specify the files or classes that our lint rule should apply to.
  2. 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.).
  3. 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:

  1. 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.
  2. When: We filter those classes to get only the ones with names ending with ViewModel.
  3. 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:

  1. 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.
  2. When: We filter those classes to get only the ones with names ending with ViewModel.
  3. 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:

  1. Either fix the violations by refactoring our ViewModels to extend the BaseViewModel.
  2. 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")
                    }
                }
            }
        }
    }
}
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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

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:

Enhanced Test Pyramid with the addition of 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:

  1. Given: We take all functions in the production code.
  2. When: We filter them to get the ones that have the @POST annotation.
  3. And: We apply an additional filter to get the functions that have at least one parameter with the @Field annotation.
  4. 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

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE
Menu