Why Compose Changes the Testing Game
Compose Test Rule and Semantic Matchers
class LoginScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun validLogin_navigatesToHome() {
var navigatedToHome = false
composeRule.setContent {
LoginScreen(
onLoginSuccess = { navigatedToHome = true }
)
}
// Enter credentials using semantic matchers
composeRule
.onNodeWithText("Email")
.performTextInput("[email protected]")
composeRule
.onNodeWithText("Password")
.performTextInput("secure123")
// Click login button
composeRule
.onNodeWithText("Sign In")
.performClick()
// Wait for async login operation
composeRule.waitUntil(timeoutMillis = 5000) {
navigatedToHome
}
// Verify navigation occurred
assert(navigatedToHome)
}
@Test
fun emptyEmail_showsValidationError() {
composeRule.setContent {
LoginScreen(onLoginSuccess = {})
}
// Leave email empty, enter password
composeRule
.onNodeWithText("Password")
.performTextInput("secure123")
composeRule
.onNodeWithText("Sign In")
.performClick()
// Verify error message appears
composeRule
.onNodeWithText("Email is required")
.assertIsDisplayed()
}
@Test
fun loadingState_showsProgressIndicator() {
composeRule.setContent {
LoginScreen(
onLoginSuccess = {},
initialState = LoginUiState.Loading
)
}
composeRule
.onNodeWithContentDescription("Loading")
.assertIsDisplayed()
composeRule
.onNodeWithText("Sign In")
.assertIsNotEnabled()
}
}Testing Stateful Components with Fake ViewModels
// Production ViewModel
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val userRepo: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<ProfileState>(ProfileState.Loading)
val state: StateFlow<ProfileState> = _state.asStateFlow()
fun load(userId: String) {
viewModelScope.launch {
_state.value = try {
ProfileState.Success(userRepo.getUser(userId))
} catch (e: Exception) {
ProfileState.Error(e.message ?: "Failed to load")
}
}
}
fun onLogout() { /* ... */ }
}
sealed interface ProfileState {
data object Loading : ProfileState
data class Success(val user: User) : ProfileState
data class Error(val message: String) : ProfileState
}
// Fake ViewModel for tests -- no real dependencies
class FakeProfileViewModel : ViewModel() {
private val _state = MutableStateFlow<ProfileState>(ProfileState.Loading)
val state: StateFlow<ProfileState> = _state.asStateFlow()
fun setState(newState: ProfileState) {
_state.value = newState
}
fun onLogout() { /* track calls if needed */ }
}
// Tests drive the composable through every state
class ProfileScreenTest {
@get:Rule val composeRule = createComposeRule()
private val fakeViewModel = FakeProfileViewModel()
@Test
fun successState_displaysUserInfo() {
val user = User(id = "1", name = "Rocky", email = "[email protected]")
fakeViewModel.setState(ProfileState.Success(user))
composeRule.setContent {
ProfileScreen(state = fakeViewModel.state.collectAsState().value)
}
composeRule.onNodeWithText("Rocky").assertIsDisplayed()
composeRule.onNodeWithText("[email protected]").assertIsDisplayed()
}
@Test
fun errorState_displaysRetryButton() {
fakeViewModel.setState(ProfileState.Error("Network error"))
composeRule.setContent {
ProfileScreen(state = fakeViewModel.state.collectAsState().value)
}
composeRule.onNodeWithText("Network error").assertIsDisplayed()
composeRule.onNodeWithText("Retry").assertIsDisplayed()
}
}Screenshot Testing with Roborazzi
// build.gradle.kts
plugins {
id("io.github.takahirom.roborazzi") version "1.32.0"
}
dependencies {
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.32.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.32.0")
testImplementation("org.robolectric:robolectric:4.14")
}
// Screenshot test
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
class ButtonScreenshotTest {
@get:Rule val composeRule = createComposeRule()
@Test
fun primaryButton_default() {
composeRule.setContent {
AppTheme {
PrimaryButton(text = "Submit", onClick = {})
}
}
composeRule
.onNodeWithText("Submit")
.captureRoboImage("screenshots/primary_button_default.png")
}
@Test
fun primaryButton_disabled() {
composeRule.setContent {
AppTheme {
PrimaryButton(
text = "Submit",
onClick = {},
enabled = false
)
}
}
composeRule
.onNodeWithText("Submit")
.captureRoboImage("screenshots/primary_button_disabled.png")
}
@Test
fun profileCard_lightAndDark() {
val user = User(id = "1", name = "Rocky", email = "[email protected]")
listOf(false, true).forEach { isDark ->
composeRule.setContent {
AppTheme(darkTheme = isDark) {
ProfileCard(user = user)
}
}
val suffix = if (isDark) "dark" else "light"
composeRule
.onNodeWithTag("profile_card")
.captureRoboImage("screenshots/profile_card_$suffix.png")
}
}
}
// Gradle commands:
// ./gradlew recordRoborazziDebug -- Generate reference screenshots
// ./gradlew verifyRoborazziDebug -- Compare against references
// ./gradlew compareRoborazziDebug -- Generate comparison reportThe Testing Pyramid for Compose Apps
Key Takeaways
- 1Compose tests query a semantic tree, not a view hierarchy -- this makes tests more stable across refactors and faster to write.
- 2Prefer semantic matchers (onNodeWithText, onNodeWithContentDescription) over test tags. Test what the user sees, not implementation details.
- 3Fake ViewModels with controllable state let you drive composables through every state (loading, success, error, empty) without real backends.
- 4Roborazzi screenshot tests run on the JVM without an emulator, catching visual regressions in seconds rather than minutes.
- 5The Compose testing pyramid: 70% unit tests (ViewModels, use cases), 20% Compose UI tests (interactions), 10% screenshot tests (visual regression).
- 6ComposeTestRule.waitUntil() replaces Espresso's idling resources for waiting on async operations in tests.
Frequently Asked
How much UI testing is enough?
Test critical user flows and edge cases. Don't test implementation details. Focus on user-visible behavior. Aim for confidence, not 100% coverage. Combine UI tests with unit tests.
How do I test async operations?
Use awaitUntil() to wait for conditions. Use fake repositories with controlled timing. Avoid Thread.sleep in tests. Use runCurrent() to advance coroutine time.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.