
Jetpack Compose has not merely introduced a new way to declare UI; it has fundamentally changed how Android UI testing is approached, structured, and reasoned about.
Traditional View-based UI testing relies heavily on view hierarchies, resource IDs, and fragile synchronization mechanisms. Jetpack Compose replaces this with a semantics-driven testing model, where tests interact with UI meaning rather than UI implementation.
This article walks through practical, production-ready testing recipes using modern Compose testing APIs, avoiding deprecated patterns and highlighting recommended alternatives where applicable.
A conceptual shift: View tree → Semantics tree
Compose UI tests do not operate directly on composables or the layout tree. Instead, they interact with the semantics tree, which represents:
- What text is visible
- What actions are available
- Whether an element is enabled, selected, or disabled
- Accessibility-relevant information
This design brings two important consequences:
- Tests describe user-observable behavior, not internal layout details.
- Developers must intentionally design semantics (test tags, content descriptions, merging behavior) to keep UI testable and accessible.
This is a structural improvement, not just a tooling change.
Test setup (current and recommended)
Dependency management
The recommended approach is to use the Compose BOM to keep all Compose artifacts aligned.
dependencies {
implementation(platform("androidx.compose:compose-bom:2025.12.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Notes (important):
- ui-test-junit4 is the primary entry point for Compose UI tests.
- ui-test-manifest is required only when using createComposeRule() (no Activity).
- If you use createAndroidComposeRule<YourActivity>(), the manifest dependency is not required.
No deprecated testing artifacts are used above.
The example screen: Login UI designed for testability
@Immutable
data class LoginUiState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val error: String? = null,
val success: Boolean = false
)
@Composable
fun LoginScreen(
state: LoginUiState,
onEmailChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onLoginClick: () -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Sign in", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.email,
onValueChange = onEmailChange,
label = { Text("Email") },
modifier = Modifier.testTag("email")
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text("Password") },
modifier = Modifier.testTag("password")
)
Spacer(Modifier.height(12.dp))
Button(
onClick = onLoginClick,
enabled = !state.loading &&
state.email.isNotBlank() &&
state.password.isNotBlank(),
modifier = Modifier.testTag("login_button")
) {
if (state.loading) {
CircularProgressIndicator(
modifier = Modifier
.size(18.dp)
.testTag("loading"),
strokeWidth = 2.dp
)
Spacer(Modifier.width(8.dp))
}
Text("Continue")
}
state.error?.let {
Spacer(Modifier.height(12.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag("error")
)
}
if (state.success) {
Spacer(Modifier.height(12.dp))
Text("Welcome!", modifier = Modifier.testTag("success"))
}
}
}
Why this structure matters for tests
- Stateless composables with explicit state inputs
- Stable testTag() usage (recommended over text matching in most cases)
- No hidden side effects
- UI behavior entirely driven by state
This design dramatically simplifies UI testing.
Recipe 1: Happy-path UI test (no Activity, no flakiness)
class LoginScreenTest {
@get:Rule
val rule = createComposeRule()
@Test
fun loginSuccess_showsWelcomeMessage() {
var state by mutableStateOf(LoginUiState())
rule.setContent {
LoginScreen(
state = state,
onEmailChange = { state = state.copy(email = it) },
onPasswordChange = { state = state.copy(password = it) },
onLoginClick = { state = state.copy(success = true) }
)
}
rule.onNodeWithTag("email")
.performTextInput("neo@example.com")
rule.onNodeWithTag("password")
.performTextInput("secure-password")
rule.onNodeWithTag("login_button")
.performClick()
rule.onNodeWithTag("success")
.assertIsDisplayed()
}
}
No deprecated APIs used here.
This is the recommended baseline pattern for Compose UI testing.
Recipe 2: Semantics merging (the most common test failure cause)
Compose may merge child semantics into a parent (especially for buttons, rows, or cards). When this happens, nodes may appear “missing” during tests.
Debugging approach (recommended)
rule.onRoot().printToLog("SEMANTICS_TREE")
Targeting unmerged semantics (when required)
rule.onNodeWithText(
text = "Continue",
useUnmergedTree = true
).assertIsDisplayed()
Guidance:
- Do not default to useUnmergedTree = true
- Prefer explicit testTag() usage
- Use unmerged trees only for diagnosis or edge cases
This behavior is expected and not a bug.
Recipe 3: Deterministic time and async UI (modern alternative to sleeps)
❌ Avoid
- Thread.sleep()
- Arbitrary delays
- Manual idling logic
✅ Recommended tools
- waitUntil { condition }
- Compose test clock (mainClock) when animations are involved
rule.waitUntil(
conditionDescription = "Loading indicator visible"
) {
rule.onAllNodesWithTag("loading")
.fetchSemanticsNodes()
.isNotEmpty()
}
This is the correct, non-deprecated way to wait for UI state changes in Compose.
Recipe 4: Compose + View interoperability (real-world migration scenario)
Compose testing APIs are designed to coexist with Espresso during incremental migrations.
Recommended practice:
- Use Compose test APIs for Compose UI
- Use Espresso only where View-based UI still exists
- Do not force one system to test the other
This avoids brittle tests and keeps migration effort controlled.
Recipe 5: Accessibility checks as part of UI testing (modern approach)
Compose supports automated accessibility validation directly from tests.
Recommended setup
- Use the accessibility testing artifact
- Enable checks explicitly in tests
rule.enableAccessibilityChecks()
rule.onNodeWithTag("login_button")
.assertExists()
This approach is current and supported and integrates accessibility validation into CI pipelines without manual audits.
Deprecated APIs: what to avoid (and what to use instead)
Deprecated / DiscouragedRecommended AlternativeThread.sleep()waitUntil {} or test clockOld performSemanticsAction overloadsFluent SemanticsNodeInteraction APIsText-only selectors everywhereModifier.testTag()Manual IdlingResourcesCompose’s built-in synchronization
All examples in this article use current, non-deprecated APIs.
A practical testing strategy that scales
- Composable unit tests
Using createComposeRule() for fast, deterministic checks. - Integration UI tests
Using createAndroidComposeRule() for navigation and wiring. - Accessibility validation
Enabled automatically in CI for regression protection. - State-driven testing
UI behavior asserted via state changes, not implementation details.
This approach keeps test suites fast, stable, and meaningful as apps grow.
Closing perspective
Jetpack Compose UI testing is not a re-implementation of Espresso. It represents a shift toward testing intent, behavior, and meaning, powered by the same semantics that drive accessibility.
When UI tests are written against semantics instead of structure, they become:
- More resilient to UI refactors
- Easier to understand
- Aligned with real user interaction
That alignment is what truly reshapes Android UI testing — not just Compose itself.
How Jetpack Compose Is Reshaping UI Testing: Real-World Recipes was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.