Clean Android multi-module offline-first scalable app in 2022 (part 2) — including Compose UI testing, Turbine, MockK, JUnit5, Renovate, GitHub Actions, ktlint and detekt

Krzysztof Dąbrowski
codequest
Published in
14 min readDec 23, 2022

--

Hot air balloons are cool, let’s keep them as a theme photo (found once again on Unsplash)

Welcome to the second, ultimate part of the article about designing and developing clean, scalable Android apps in 2022. If you haven’t read the first part, I encourage you to do it here as we’re going to continue from where we left off previously.

Last time we discussed several technologies strictly related to production code. Such an approach would only be sufficient when starting our software engineering journey. Nowadays, there are multiple additional techniques that make developer life easier and safer. In this article, I will present a curated list of such techniques, based on what I encountered over the years of Android apps development.

Feel free to dig deeper into the topic by following the external links, including the sample project at the end of this article.

Quick glimpse of the sample project app

Unit testing — overview & concerns

When you look at the concept of the test pyramid, unit tests form the layer of its bottom. They are the fastest to run, the cheapest to maintain and I also feel like they are the least flaky of all types. I know that some big business-oriented companies might disagree with this but I believe they are the most crucial ones you can have in your project. And while you may be able to move forward without UI and integration tests, skipping a sufficient amount of unit tests is a recipe for disaster in an Android project.

Unfortunately, oftentimes unit tests (and written tests in general) are not treated as first-class citizens. I believe we are all guilty to some extent — developers sometimes forget to add unit tests for new functionalities, or they say that “they will add it later”, and “later” never comes. On the other hand, our employers/clients may not see the business value in them and software engineers don’t know how to sell the concept of writing unit tests being a vital part of app development, not far behind pushing new functionalities to the user base. Finally, and probably many would agree, I feel like the available testing toolset could get some more love. In the previous article I said:

All the libraries in the previous sections are being created by the Square, Google or Kotlin team. So there is a big chance they will not be deprecated overnight, you may find them in multiple projects so they will be battle-proven in many (also large-scale) production apps, and when having an issue — there will be an answer on StackOverflow.

This quote refers to production code. What does it look like for the test code?

The most popular test framework for Android is JUnit. There are also other frameworks that are dedicated to Kotlin rather than the whole JVM — like Kotest or Spek. I haven’t tested the former yet (you can expect the follow-up when I’ll catch up) but the latter suffers from what I mentioned above — lack of support, leaving project maintenance in the hands of the community. This is suboptimal for long-term business projects so we have to resort to JUnit.

Unit testing — journey with JUnit5, Turbine & MockK

Imagine you wanted to unit test Android app code that utilizes Kotlin Coroutines and Flows. The first thing you’d do is look at the coroutines’ Get Started section in the Android documentation and read through it until we reach the Setting the Main dispatcher section. Quoting:

A common pattern to avoid duplicating the code that replaces the Main dispatcher in each test is to extract it into a JUnit test rule:

// Reusable JUnit4 TestRule to override the Main dispatcher

class MainDispatcherRule

(…)

Wait, what? JUnit4? The current version of JUnit (as of December 2022) is 5.9.1. JUnit 5.0.0 was released over 5 years ago, in September 2017. Why recommend software so dated for a topic as crucial as unit tests? This is super confusing for new Android developers and frustrating for others.

But okay, there is a community Gradle plugin that allows the execution of JUnit5 tests, so we add it to the project. Sadly, the next steps don’t look simpler whatsoever:

  • We want to create a TestRule for our Dispatchers but there are no Rules in JUnit5! This API is now called Extensions.
  • We fix our test code to the new API, based on Medium articles like this one.
  • Then we notice that Kotlin Coroutines test APIs have also drastically changed in the 1.6.0 version (December 2021).
  • We can’t just copy-paste thoughtlessly so we fix it iteratively. And the new API offers a lot of changes as well.

It is a common scenario for Kotlin Coroutines to coexist with Kotlin Flow code. Testing Flows can be done without additional tools like described in Android docs. However, to make things easier and more readable, we will use a third-party tool called Turbine. It is a small library, but at least with decent support from its creators — Cash App and Jake Wharton. Its usage is also suggested as an alternative Flow testing approach in the Android documentation linked above.

Oftentimes classes that should be tested have some external dependencies. Thanks to the Dependency Injection technique, we are able to provide test objects (called “test doubles”) with ease. There are multiple types of them like fakes, mocks, stubs, dummies (Pragmatists’ blog has a decent summary of differences between them) but due to libraries’ naming conventions like Mockito or MockK, the procedure is often referred to as “mocking”. Of course, we are not obliged to use a third-party library to do that. But again: if it’s possible to write unit tests more easily, I suggest doing so, as it allows us to cover more test scenarios in a shorter time. Ultimately it will make our production code safer.

The last thing is the naming convention of test functions. There are many approaches — in the sample project I use one that starts with the word “should” but some alternative approaches are described in this article. On top of that, as Kotlin coding conventions suggest, we can use spaces enclosed in backticks which will increase the readability of test method names — but remember that applies only for unit tests, not for UI tests.

Putting it all together, this is how our example ViewModel test looks like:

As for me, this whole configuration looks over-complicated for such a simple task like writing unit tests — if the toolset was more standardized by Google or Kotlin (like they did for production code), writing them would be far simpler, and hence safer for application developers and users. Thus we resort to using third-party or community libraries to do it in an effective manner.

UI testing — Jetpack Compose & Hilt

So now we know how overengineered Android unit testing can be. Is it any different in the Jetpack Compose world, with Android UI tests?

Its new API looks similar to that of the Espresso UI testing framework and we can distinguish three types of Compose tests:

  1. Unit UI tests using ComposeContentTestRule. It validates your Composable in full separation, without access to Activity. Useful for testing stateless Composables.
  2. UI tests using AndroidComposeTestRule. It validates your Composable inside an Activity, preferably an empty one, e.g. ComponentActivity. Useful when activity resources are needed, for example in Screen Composables.
  3. Integration UI tests using AndroidComposeTestRule. It validates your Composable inside your Activity, which in the case of single-activity architecture would be MainActivity. Useful for root Composables that includes ViewModel as an argument. Therefore Hilt will be used here as well.

Starting with the most straightforward, let’s try to write our first Compose unit test. A good indicator for which logic should be tested is usually the presence of conditional statements. Fortunately, RocketListContent Composable has one if statement for showing Divider or not.

We’ll start off by creating a test class in the androidTest package (if you don’t see it, remember to change the build type from release to debug — otherwise it can be a bit of a head-scratcher!). Then we define ComposeContentTestRule and set its content (that is: our Composable being tested) with some dummy data provided as the parameter.

However, there are no IDs for view components in Compose which makes testing somewhat trickier. In this scenario, we don’t have any sort of text to be found so we are obliged to use onNodeWithTag finder. I’m not a fan of putting test code into production code (does anyone remember IdlingResource?) and I think many will agree that’s a red flag. I would avoid using it unless absolutely necessary, such as in the case here.

In the end, our first Compose test may look like this:

Thankfully, we don’t have to use the onNodeWithTag finder most of the time. For example, if we want to test Text or Image Composables (or its derivatives), we will utilize onNodeWithText and onNodeWithContentDescription finders respectively. It is possible to use them with hardcoded String values, however, just like in production code, it is safer to use String resources. The recommended way to retrieve them in Compose is to write a test using AndroidComposeTestRule and add an empty Activity like ComponentActivity.

RocketsScreen is the one Composable that uses few String resources, so it should be a good candidate for a Compose UI test. Just like in regular unit UI tests, we create a test class, then define an AndroidComposeTestRule along with the Activity and set its content with input data — in this case RocketsScreen’s UiState. The next step is to retrieve String resources from Activity Context and store them in local variables. The rest is pretty self-explanatory and consists of calling proper Compose API methods via finders and assertions:

But what about integration UI tests written in Compose? There is nothing to be found in Testing your Compose layout documentation. Hilt testing guide examples use the old View system so it can serve as a guidepost, but for less experienced Android Developers it might not be enough.

When I first tried to write a Compose integration UI test (that was when Jetpack Compose became stable, in July 2021), I found literally only one documented place about it in the whole Internet. The linked article by Michel has very high quality and I definitely recommend reading it for more detailed instructions. To not make this article a chore to read, I will just show how to implement it in a TL;DR matter.

Source: https://gfycat.com/pl/affectionateimmensebronco
  1. Add custom Hilt testInstrumentationRunner in build.gradle.kts.

2. Provide dummy data in fake test implementation.

3. Create a test class with HiltAndroidTest annotation and two rules — HiltAndroidRule (takes precedence) and AndroidComposeTestRule.

4. Setup Hilt’s Rule and set Activity’s context (this changed in Compose 1.2.0!) with Composable under test (and injected ViewModel)

5. Write the rest of the UI test as usual.

And voilà, your Compose UI tests are now ready to roll.

Was it time-consuming? Hell yeah.

Was it worth it? Well, it depends.

From my commercial experience, app developers rarely write UI tests in business projects. Even when these tests exist, they are often very slow and flaky. Besides that, they can also blow the phone’s battery if run continuously which can be very dangerous in hybrid/remote work models. Judging by the rising popularity of Maestro framework (more than 2800 stars on GitHub in less than 5 months from initial release), there is undoubtedly a niche to be filled. As they say in latin, natura horret vacuum, so I expect it to happen at some point.

Static code analysis — ktlint & detekt

Assume our application code is properly designed and developed in a scalable manner. We also have multiple sets of tests, as befits a rational software engineer. We should feel happy, our code looks clean… for us. But does it for other developers too?

In the “Unit testing” section I mentioned there is something called Kotlin coding conventions. These are rules created by Kotlin creators on how the code should be organized and formatted. Thanks to consolidated coding conventions, developers are able to work more efficiently in a team. Newcomers’ onboarding process is faster because they see at least some similarities to what they’re used to from previous projects. Long story short, a standardized codebase means saving time and money while making developers feel better about the project. So how can it be accomplished?

There are multiple tools for analyzing the codebase in a static manner (that is: without running the application). My personal tools of choice for that are ktlint and detekt and they complement each other perfectly. Ktlint is a linter for Kotlin and is responsible mainly for code formatting to abide Kotlin conventions. It works out of the box via the Gradle plugin but starting with KtLint v1.0 it might be advised to configure it via .editorconfig file. On the other hand, detekt is a proper code analysis tool that detects code smells, code complexity, potential performance issues etc. It is highly customizable via yaml config file, extra rule sets and can be integrated using the Gradle plugin as well.

Both tools are responsible for keeping our Kotlin code compliant and consistent across the projects. However, Jetpack Compose has its own set of rules that should be followed to keep your Kotlin UI code clean. There is an official source from Google in text form, but thankfully detekt has created additional configuration based on it so it can be used to update the default yaml file. There is also a good Compose ruleset created by the Twitter team that can be used with both ktlint and detekt, but after a wave of layoffs and resignations, its future development is uncertain.

One tool to rule them all — GitHub Actions

Our application codebase is now protected by a set of unit tests, Compose UI tests and static code checkers… as long as they are used frequently. Running all these Gradle commands manually and on a regular basis can be cumbersome. And even if you remembered about doing it, this would happen only on your machine. What about bigger business environments? Or what if you’d have a memory of a goldfish?

Fortunately, there is a software engineering practice that enables a more automatic approach. It is called Continuous Integration and is often referred to as ‘CI’. There are many CI tools available, including Bitrise which is a popular choice for mobile apps, but due to the fact that the project is hosted on GitHub, I chose GitHub Actions to ensure seamless compatibility. For more information about CI, GitHub Actions or yaml syntax I would encourage you to check out this article by Kashif. Here I will only focus on the heart of our CI pipeline — a yaml config file.

Firstly, we define a name for our pipeline — let it be CI workflow. Then we tell GitHub which actions should trigger it. We’d like to run our automated flow every time a Pull Request is opened in our GitHub repository. This way, all code will be verified before being merged into common branches (as long as no one pushes changes directly to them, which should not be allowed by proper repository configuration). Finally, we specify which tasks should be executed. To make the example more clear, I’ve created only one job called build that is responsible for every single step, but they can be subdivided more finely.

Inside this task, I chose the operating system on which it will be run. MacOS is 10x more expensive than Linux but is highly recommended for Android UI test execution. Set a 30 minute timeout and we can follow these steps:

  1. Checkout the repository via actions/checkout. The recommended way of defining its versions is using major ones like @v3 .
  2. Setup JDK environment using Zulu distribution of Java 17 (required since April 2023 and Android Gradle Plugin 8.0.0 release).
  3. Setup Gradle using gradle-build-action for easier configuration.
  4. Run ktlint and detekt commands — these are the fastest checks, so they should be run first.
  5. Assemble the application with more detailed logs — to make sure our application would build properly.
  6. Run unit tests.
  7. Run instrumentation (UI) tests on API 26 (Android 8.0, the lowest version the project supports).

Moreover, I deliberately don’t use a matrix build, as it looks like it runs more stable on older system versions. It is also a faster and cheaper solution that Jake Wharton has chosen for one of Cash App’s projects.

Bonus — automated dependency updates with Renovate

We can even go one step further with project automation. It is a common scenario that we should update our third-party dependencies — to benefit from new features and to have fewer bugs in our applications. However, updating them manually (for example searching for the latest version available on mvnrepository.com, then “bumping” it by hand) is a very time-consuming process — especially for bigger projects that can have more than 200 external libraries added. How to deal with it more efficiently?

The most popular tool on GitHub for that is Dependabot. Unfortunately, the GitHub Dependabot team is not so eager to implement Version Catalogs support over the last 1.5 years. It caused such a bizarre situation, that people from the Android GitHub team have recently created a Pull Request with its implementation. This should not come as a big surprise, because Android uses the Gradle build system whose developers would benefit the most from this support, but still… As of December 2022, the PR is ready to review so it is not a finished job yet.

That’s why I decided to use its competitor — Renovate by Mend. It supports Version Catalogs since September 2021 and I can confirm it works flawlessly. Its documentation is very clear, from first-time usage and basic settings to the list of configuration options.

I won’t go into the details of each parameter because the list linked above is very extensive. For my needs, I set dependency-update label on each Pull Request created by the bot and merge all of them into a single PR if the update type is minor or patch. Thanks to this configuration, I can go through all updates in one go and there is less of noise regarding the number of git branches on remote.

This is how it looks for major updates that are usually more problematic to deploy into the app:

Renovate will:

  • link release notes for each dependency update,
  • change the numbering between older and newer version,
  • count how old the package is,
  • tell how many people have already adopted the change (within Renovate),
  • display the percentage of updates that have passed tests for this particular version
  • and show the overall confidence level regarding the merge and its success for the application.

For the minor and patch updates it’s a bit more colorful:

Of course, the final decision whether a dependency update should be merged or not is still on the reviewer or code owner's side.

Conclusion

If you’ve reached this point, thank you — and good job! We traversed plenty of different programming worlds for the Android platform: from overengineered unit testing, through time-consuming Compose UI testing, much less time-consuming static code analysis libraries, ending with tools that save our valuable time via process automation.

Compared to the production code that I discussed previously, most of the tools used today come from third-party companies. There is much less standardization which means that we cannot be sure we will use the same setup over the next few years. The best example is the Maestro library which has the potential to overthrow all Google libraries that are dedicated to UI testing — whether it is Espresso or a new one dedicated to Compose. Why? Because it seems like they learn from others’ mistakes and for Google… let’s say it depends on the particular product team.

Here is the link to my sample project’s repository.

For 2023, I wish you high stability in chosen programming tools (just as in life), fewer bugs in production code and a Happy New Year!

If you learned something new today, multi-clapping is always appreciated 🙏

Thanks to Filip Pietroń and Damian Koźlak for their valuable feedback.

--

--