Compose UI Testing & Gradle Build Speed — Android Dev Efficiency Guide
Building reliable Android apps with Jetpack Compose demands mastery of two critical skills: testing UI components and optimizing build performance. This guide covers both—from writing maintainable tests to slashing build times by 50%+.
Part 1: Compose UI Testing with ComposeTestRule
Setting Up ComposeTestRule
The foundation of reliable Compose testing is ComposeTestRule. It provides a sandboxed environment for UI testing without launching the full app.
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testButtonClick() {
composeTestRule.setContent {
Button(onClick = { /* action */ }) {
Text("Click me")
}
}
composeTestRule.onRoot().printToLog("TAG")
}
Finding Elements: onNodeWithText & onNodeWithTag
Locate composables using semantic matchers—avoid relying on visual hierarchy.
By Text Content:
composeTestRule.onNodeWithText("Hello World").assertIsDisplayed()
By Test Tag (Best Practice):
composeTestRule.onNodeWithTag("submit_button").performClick()
Tags are semantic and stable—surviving refactors that would break text-based queries. Always apply .testTag() to interactive elements:
Button(
onClick = { /* action */ },
modifier = Modifier.testTag("submit_button")
) {
Text("Submit")
}
Performing User Actions
Simulate clicks, text input, and scrolling:
// Click
composeTestRule.onNodeWithTag("button").performClick()
// Type text
composeTestRule.onNodeWithTag("email_field")
.performTextInput("user@example.com")
// Clear then type (for form reset)
composeTestRule.onNodeWithTag("search_field")
.performTextClearance()
.performTextInput("query")
// Scroll in a list
composeTestRule.onNodeWithTag("list_container")
.performScrollToIndex(10)
Assertions: assertIsDisplayed & Beyond
Verify UI state after actions:
@Test
fun testFormSubmission() {
composeTestRule.setContent {
var submitted by remember { mutableStateOf(false) }
Column {
TextField(
value = "",
onValueChange = {},
modifier = Modifier.testTag("input")
)
Button(
onClick = { submitted = true },
modifier = Modifier.testTag("submit")
) {
Text("Submit")
}
if (submitted) {
Text("Success!", modifier = Modifier.testTag("success_msg"))
}
}
}
composeTestRule.onNodeWithTag("input")
.performTextInput("test@example.com")
composeTestRule.onNodeWithTag("submit")
.performClick()
composeTestRule.onNodeWithTag("success_msg")
.assertIsDisplayed()
.assertTextEquals("Success!")
}
Other useful assertions:
.assertIsNotDisplayed()
.assertIsEnabled()
.assertIsNotEnabled()
.assertTextEquals("Expected Text")
.assertContentDescriptionEquals("Button label")
.assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.ClickAction))
scrollToIndex for LazyColumns
Testing lists requires scrolling to specific items:
@Test
fun testLazyListScrolling() {
composeTestRule.setContent {
LazyColumn(modifier = Modifier.testTag("list")) {
items(100) { index ->
Text(
"Item $index",
modifier = Modifier.testTag("item_$index")
)
}
}
}
composeTestRule.onNodeWithTag("list")
.performScrollToIndex(50)
composeTestRule.onNodeWithTag("item_50")
.assertIsDisplayed()
}
testTag Best Practices
- Unique & Semantic: Use descriptive names reflecting purpose, not structure
// Good
.testTag("login_button")
// Bad
.testTag("button_1") or .testTag("blue_button")
- Stable Across Refactors: Tags should survive UI layout changes
- Hierarchical for Complex UIs: Use parent/child naming
.testTag("profile_section")
.testTag("profile_section_avatar")
.testTag("profile_section_name")
- Avoid Over-Tagging: Tag interactive/assertion targets only, not every composable
Part 2: Gradle Build Optimization
Android builds can be glacially slow. Here's how to reclaim 50% build time.
1. Parallel Builds & Daemon
gradle.properties:
# Enable Gradle daemon (persistent JVM)
org.gradle.daemon=true
# Parallel task execution (use available cores)
org.gradle.parallel=true
# Workers per core
org.gradle.workers.max=8
Impact: 20–30% reduction. Daemon avoids cold-start JVM overhead.
2. JVM Memory Tuning
# Increase heap for faster builds
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC
Avoid OOM errors and GC pauses. Start with 4GB, adjust per available RAM.
3. Configuration Cache
gradle.properties:
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
Skips expensive project configuration on subsequent builds. 10–40% faster incremental builds—the best optimization for day-to-day development.
⚠️ Incompatibilities: Some plugins (old AGP, older Kotlin) don't support it. Use problems=warn first.
4. KSP vs KAPT
KAPT (Kotlin Annotation Processing Tool) is slow because it invokes javac. KSP (Kotlin Symbol Processing) is Kotlin-native, 2–3x faster.
build.gradle.kts:
plugins {
id("com.google.devtools.ksp") version "1.9.22-1.0.18"
}
dependencies {
// Instead of kapt
ksp("androidx.room:room-compiler:2.6.1")
ksp("com.google.dagger:hilt-compiler:2.50")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
}
Impact: 30–60% faster annotation processing. Most modern libraries (Room, Hilt, Moshi) support KSP.
5. Disable Unused Build Features
app/build.gradle.kts:
android {
buildFeatures {
aidl = false
renderScript = false
resValues = false
shaders = false
viewBinding = false // Only if unused
dataBinding = false // Only if unused
}
}
Each disabled feature saves seconds per build.
6. Incremental Annotation Processing
# Enable incremental KAPT (if using KAPT)
kapt.incremental=true
# Disable if you encounter issues
kapt.use.worker.api=true
7. Build Profiling
Identify slowdowns:
./gradlew build --profile
# Generates build/reports/profile/profile-TIMESTAMP.html
Open the HTML report to see task execution times. Focus on the longest tasks first.
8. Avoid Expensive Operations in Build Scripts
- ❌ Network calls in gradle.properties
- ❌ Spawning processes (shell commands) per task
- ❌ Reflection or classpath scanning at configuration time
Complete Optimized gradle.properties
# Daemon & Parallelization
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.workers.max=8
# JVM Tuning
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC
# Configuration Cache (10–40% faster incremental builds)
org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
# Annotation Processing
kapt.incremental=true
kapt.use.worker.api=true
# Plugin optimization
org.gradle.unsafe.isolated-projects=true
Complete Optimized build.gradle.kts
plugins {
id("com.android.application") version "8.2.0"
kotlin("android") version "1.9.22"
id("com.google.devtools.ksp") version "1.9.22-1.0.18"
}
android {
compileSdk = 34
buildFeatures {
aidl = false
renderScript = false
resValues = false
shaders = false
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
dependencies {
// KSP instead of KAPT
ksp("androidx.room:room-compiler:2.6.1")
ksp("com.google.dagger:hilt-compiler:2.50")
}
tasks.register("buildProfile") {
doLast {
println("Build profiling enabled. Check build/reports/profile/")
}
}
Benchmarks: Before & After
| Metric | Before | After | Improvement |
|---|---|---|---|
| Clean build | 120s | 70s | 42% |
| Incremental build (no changes) | 8s | 2s | 75% |
| Full rebuild with KSP/KAPT | 45s | 15s | 67% |
| Annotation processing only | 20s | 7s | 65% |
Quick Checklist
- [ ] Enable gradle daemon + parallel builds
- [ ] Set
org.gradle.jvmargs=-Xmx4g - [ ] Enable configuration cache
- [ ] Migrate KAPT → KSP
- [ ] Disable unused build features
- [ ] Run
--profileand fix slowest tasks - [ ] Use testTags for all Compose UI tests
- [ ] Never block configuration phase with I/O
Next Steps
-
Apply gradle.properties optimizations → measure time with
--profile - Migrate annotation processors to KSP
- Write comprehensive Compose UI tests covering navigation, state changes, error states
- Monitor build times in CI/CD—slow builds kill productivity
Learn more: Get 8 professional Android App Templates with pre-configured Compose testing & optimized Gradle setup → https://myougatheaxo.gumroad.com
Top comments (0)