Improving unit test performance in MEGA Android

MEGA on 2024-01-11

Improving unit test performance in MEGA Android

By Erick Sumargo, Senior Android Engineer, Mobile, MEGA

Overview

MEGA Android codebase has +3K unit tests running in our daily pipelines.

With build scan’s support, we observed that every time we run the unit test, the build system will execute following main tasks:

Figure 1. Main tasks in unit test compilation

Out of these tasks, main source compilation takes the longest as it is executed in the same phase as assembling the APK. This means all its sub-tasks, e.g., injectCrashlyticsBuildIds, kapt*, etc. are running, which can be a lot of overhead just for running the unit test.

This post narrates our analysis effort in auditing and inventing a “lite” test configuration — a configuration for disabling unnecessary Gradle tasks, resulting in faster unit test compilation time, both on local and CI.

Disclaimer The experiment is conducted in local machine with following specs:

- Operating system: macOS 13.5 (aarch64) - CPU cores: 10 cores (MacBook Pro M1 Max) - Max Gradle workers: 10 workers - Java runtime: JetBrains s.r.o. OpenJDK Runtime Environment 17.0.6+0–17.0.6b829.9–10027231 - Java VM: JetBrains s.r.o. OpenJDK 64-Bit Server VM 17.0.6+0–17.0.6b829.9–10027231 (mixed mode) - Max JVM memory heap size: 10 GiB

injectCrashlyticsBuildIds

Let’s start with the low-hanging fruit. We use Crashlytics to track our app quality, however its Gradle task is registered in the build graph as default, which doesn’t bring any contribution at all for running the unit test. Disabling it can easily save us ~10 seconds with the following script:

tasks.matching { it.name.startsWith("injectCrashlytics") }
     .configureEach { enabled = false }

kaptKotlin

kapt is notoriously known for slowing down builds due to its processing nature as an interoperability bridge so Java’s APT can run. kapt often breaks incremental compilation because of its sensitivity to classpath changes and tendency to rerun often. This means the build-cache is likely invalidated every time we change main sources while iterating on tests, although no ABI has changed.

But unlike the previous task which we can easily disable, we can’t disable this kapt task completely because some of its subtasks, e.g., data binding, are still needed to have main sources successfully compile. Instead, the best we can do is to audit registered kapt libraries, and remove unnecessary ones, so kapt can run efficiently during unit test compilation.

Dagger-Hilt

Our unit tests align with Hilt testing philosophy where we directly instantiate the SUT by passing in fake or mock dependencies, so no Hilt injection required. That said, both kapt and kaptTest configurations for this library are not necessary to run because its generated classes are not needed at all.

The efficiency logic is, if kapt is disabled, then the java stubs gen task is skipped. Since no java stubs are generated, the annotation processor has no input to process, which usually takes rounds to emit other java sources. Because there is no java source output, javac task has much less sources to compile, which results in a huge time-saving gain.

To prove this thesis, we ran ./gradlew :app:testDebugUnitTest and all tests still passed.

Google’s AutoValue

This is simply an unused kapt in some modules which we probably forgot we could now remove after some refactoring work. Despite having no processor input, disabling unused kapt can still save couple of seconds because of the configuration warm-up time.

Hilt Plugin

Since kapts for Dagger-Hilt have been disabled, running tasks from the Hilt plugin brings no meaningful result. By removing the dagger.hilt.android.plugin we saved ~20 seconds.

kaptUnitTestKotlin

After removing all kaptTest configurations, we notice that this task was somehow still running. Although Gradle mark it NO-SOURCE which means no processor inputs, it can still take few seconds to run each time.

We reached out to the community about this weird behavior, and it appears to be expected since any kapt task extends the main kotlin-kapt plugin as discussed in KT-29481. They also redirected me to this article which addressed the exact same issue. The suggested workaround without breaking downstream kotlinc tasks is to simply remove the task with the following script:

tasks.matching { it.name.startsWith("kapt") && it.name.endsWith("TestKotlin") }
     .configureEach { enabled = false }

Result

With the above tasks filtered out, the unit test compilation time is reduced up to ~2 minutes (baseline ~8 minutes+25%) for clean build, ~40 seconds (baseline ~160 seconds+25%) for incremental build, and saves ~15 MB size of generated class from Dagger-Hilt. You can see the new scan result and the benchmark measurement for more details.

The improvement for incremental build actually depends on the number/type of modified module(s). For instance, we observe ~40 seconds improvement when modifying :app module only.

Conclusion

With such positive results, we continued to observe if the configuration introduced regression, but every single test is still working fine. This means that the “lite” configuration is an absolute win with zero trade-off!

As for the next improvement, we’re looking forward to removing kapt tasks completely in unit test compilation, once we have refactored data binding usage to view binding or compose.