UI Testing with Jetpack Compose + Coroutines

UI Testing with Jetpack Compose + Coroutines (Complete Guide)

Testing Jetpack Compose UI that uses coroutines, StateFlow, and ViewModel is a must for modern Android apps.
This guide covers setup → ViewModel → Compose → coroutine-aware UI tests in a clean and practical way.


1. What We Are Testing

In Compose UI tests, we usually test:

  • UI reacts to StateFlow updates

  • Buttons trigger ViewModel coroutine calls

  • Loading / success / error states

  • One-time events (Snackbars, navigation)

👉 We do NOT test coroutines directly here — we test UI behavior caused by coroutines.


2. Required Dependencies

androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")

testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
testImplementation("androidx.arch.core:core-testing:2.2.0")


3. Sample ViewModel (StateFlow + Coroutine)

class LoginViewModel : ViewModel() {

private val _uiState = MutableStateFlow("Idle")
val uiState: StateFlow<String> = _uiState

fun login() {
viewModelScope.launch {
_uiState.value = "Loading"
delay(500)
_uiState.value = "Success"
}
}
}


4. Compose UI Using ViewModel

@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.uiState.collectAsState()

Column {
Text(text = state, modifier = Modifier.testTag("status"))
Button(
onClick = { viewModel.login() },
modifier = Modifier.testTag("loginBtn")
) {
Text("Login")
}
}
}


5. Basic Compose UI Test Setup

@get:Rule
val composeTestRule = createComposeRule()

6. Testing UI State Change (Coroutine Driven)

@Test
fun login_showsSuccessState() {
val viewModel = LoginViewModel()

composeTestRule.setContent {
LoginScreen(viewModel)
}

composeTestRule
.onNodeWithTag("loginBtn")
.performClick()

composeTestRule.waitUntil(
timeoutMillis = 1_000
) {
composeTestRule
.onAllNodesWithText("Success")
.fetchSemanticsNodes()
.isNotEmpty()
}
}

✔ Button click triggers coroutine
✔ UI updates from StateFlow
✔ Test waits safely (no Thread.sleep)


7. Using TestDispatcher with Compose (IMPORTANT)

Replace Main Dispatcher

@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val mainDispatcherRule = MainDispatcherRule()

(Use the same MainDispatcherRule from ViewModel testing)

This ensures:

  • viewModelScope runs on test dispatcher

  • Delays are controllable


8. Testing Loading → Success Flow

@Test
fun showsLoadingThenSuccess() {
val vm = LoginViewModel()

composeTestRule.setContent {
LoginScreen(vm)
}

composeTestRule.onNodeWithTag("loginBtn").performClick()

composeTestRule
.onNodeWithText("Loading")
.assertExists()

composeTestRule.waitUntil {
composeTestRule
.onAllNodesWithText("Success")
.fetchSemanticsNodes()
.isNotEmpty()
}
}


9. Testing One-Time Events (Snackbar)

ViewModel

class EventViewModel : ViewModel() {
private val _events = MutableSharedFlow<String>()
val events = _events.asSharedFlow()

fun sendEvent() {
viewModelScope.launch {
_events.emit("Done")
}
}
}


Compose

@Composable
fun EventScreen(vm: EventViewModel) {
val snackbarHostState = remember { SnackbarHostState() }

LaunchedEffect(Unit) {
vm.events.collect {
snackbarHostState.showSnackbar(it)
}
}

SnackbarHost(snackbarHostState)
}


UI Test

@Test
fun snackbar_isShown() {
val vm = EventViewModel()

composeTestRule.setContent {
EventScreen(vm)
}

vm.sendEvent()

composeTestRule
.onNodeWithText("Done")
.assertExists()
}


10. Testing Navigation Triggered by Coroutines

✔ Use fake NavController
✔ Assert navigation route
✔ No real Activity required

(Advanced topic — can cover next if you want)


11. Common Mistakes (Avoid These)

❌ Using Thread.sleep()
❌ Testing real network calls
❌ Testing ViewModel logic again
❌ Not using test tags
❌ Not replacing Main dispatcher


12. Best Practices (Must Follow)

✔ UI tests test what user sees
✔ ViewModel tests test logic
✔ Use StateFlow / SharedFlow
✔ Add testTag for stable selectors
✔ Let Compose handle recomposition


Final Summary

  • Compose UI tests work perfectly with coroutines

  • Test state-driven UI, not coroutine internals

  • Use StateFlow + collectAsState

  • Avoid sleeps, use waitUntil

  • Keep UI tests fast & deterministic

You may also like...