Android with Kotlin and Jetpack Compose · Production: Dependency Injection, Background Work, Testing and Release
Testing, Performance and Releasing on Google Play
Test ViewModels with coroutines test tools and Compose UI with semantics, keep the app fast with baseline profiles and R8, and ship through Play Console testing tracks with staged rollouts.
What you'll build
A test suite for the Repositories screen (ViewModel unit tests that run in milliseconds, plus a Compose UI test), and a release build uploaded to Google Play's internal testing track, then rolled out to users in stages.
The idea
Testing pyramid on Android:
- Unit tests (
src/test, run on the JVM): ViewModels, repositories and plain logic, using fakes.kotlinx-coroutines-testprovidesrunTestwith virtual time, sodelay(5_000)takes no real time. ReplaceDispatchers.Mainwith a test dispatcher. - UI tests with Compose (
createComposeRule()): find nodes by text or content description through the semantics tree, perform clicks, assert what's shown. Run on a device or with Robolectric. - A few end-to-end tests of critical flows.
Performance: release builds use R8 to shrink and optimise code, and baseline profiles precompile the code used at startup and in key journeys, often making startup noticeably faster. Measure with Macrobenchmark, not debug builds.
Release: upload an Android App Bundle (.aab), let Play App Signing manage the key, test on the internal and closed tracks, then use a staged rollout (for example 5%, 20%, 50%, 100%) while watching crash and ANR rates in Android vitals.
The code
class RepoViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule() // sets Dispatchers.Main to a test dispatcher
@Test
fun showsReposAfterLoading() = runTest {
val vm = RepoViewModel(FakeRepoRepository(listOf(Repo("nowinandroid", 18_000))))
assertEquals(RepoUiState.Loading, vm.uiState.value)
advanceUntilIdle()
assertEquals(RepoUiState.Success(listOf(Repo("nowinandroid", 18_000))), vm.uiState.value)
}
}
class RepoScreenTest {
@get:Rule val compose = createComposeRule()
@Test
fun errorShowsRetry() {
var retried = false
compose.setContent { RepoScreen(RepoUiState.Error("Offline"), onRetry = { retried = true }) }
compose.onNodeWithText("Offline").assertIsDisplayed()
compose.onNodeWithText("Retry").performClick()
assertTrue(retried)
}
}
// app/build.gradle.kts (release essentials)
android {
buildTypes {
release {
isMinifyEnabled = true // R8 shrinking and optimisation
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
}
// ./gradlew bundleRelease -> app/build/outputs/bundle/release/app-release.aab
Try the logic
A ViewModel test with virtual time: the fake repository "takes" two seconds, yet the test runs instantly and checks every state:
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.assertEquals
sealed interface UiState {
data object Loading : UiState
data class Success(val repos: List<String>) : UiState
}
class SlowFakeRepository {
suspend fun topRepos(): List<String> { delay(2_000); return listOf("nowinandroid") }
}
class RepoViewModel(repo: SlowFakeRepository, scope: CoroutineScope) {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state
init { scope.launch { _state.value = UiState.Success(repo.topRepos()) } }
}
fun main() = runTest {
val vm = RepoViewModel(SlowFakeRepository(), this) // the test scope runs on virtual time
assertEquals(UiState.Loading, vm.state.value)
println("starts in Loading: ok")
advanceUntilIdle()
assertEquals(UiState.Success(listOf("nowinandroid")), vm.state.value)
println("then Success: ok")
println("virtual time used: ${testScheduler.currentTime} ms")
}
Output
starts in Loading: ok then Success: ok virtual time used: 2000 ms
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Tests that use real delays or the network. They're slow and flaky; use fakes and
runTest. - Forgetting to replace
Dispatchers.Main. ViewModel tests crash on the JVM with "Module with the Main dispatcher is missing". - Measuring performance on debug builds. Debug builds aren't optimised; benchmark release builds with baseline profiles.
- Releasing to 100% at once. A crash on one device family reaches everyone; use staged rollouts and watch vitals.
Key points
- Unit-test ViewModels with fakes and
runTest's virtual time; UI-test composables through semantics. - R8 and baseline profiles make release builds smaller and faster to start.
- Ship App Bundles through internal and closed testing, then staged rollouts.
- Watch Android vitals (crash and ANR rates) after every release.
Further reading: Testing Kotlin coroutines on Android, Testing your Compose layout, Baseline profiles.
Practice questions
What does runTest's virtual time give you in ViewModel tests?
Answer
Delays are skipped by advancing a virtual clock, so code that waits seconds (timeouts, retries, debounce) runs instantly and deterministically. You control time with advanceUntilIdle or advanceTimeBy and can check states at each point.
What are baseline profiles and why do they help?
Answer
A list of classes and methods used during startup and critical user journeys, shipped with the app so Android compiles them ahead of time at install. This avoids interpreting and just-in-time compiling that code on first use, improving startup time and reducing jank.
Open in the app to mark lessons complete, take notes and track your progress.