Jetpack Compose Testing Guide: ComposeTestRule, Semantic Tree Assertions & Screenshot Testing
Jetpack Compose replaces the View hierarchy with a declarative composable tree—and that changes how you test Android UIs. Traditional Espresso tests targeting View IDs don't work. Instead, Compose provides a dedicated testing API built around the semantic tree, enabling more robust and readable tests that aren't coupled to implementation details.
The Compose Testing Mental Model
Compose tests interact with your UI through the semantic tree—an accessibility-oriented representation of your composables. When you add semantics { } blocks or use built-in composables that expose semantics (like Button, Text, TextField), the test framework can find and interact with those nodes.
Unlike Espresso where you target R.id.button, Compose tests use content descriptions, text content, roles, and custom semantic properties. This makes tests more resilient to UI refactoring.
Setting Up Compose Testing
// build.gradle.kts (app module)
dependencies {
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")
// Screenshot testing
androidTestImplementation("androidx.test.screenshot:screenshot:0.15.0")
}ComposeTestRule: The Test Entry Point
Activity-Based Tests
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun shouldShowLoginFormByDefault() {
composeTestRule.onNodeWithText("Email").assertIsDisplayed()
composeTestRule.onNodeWithText("Password").assertIsDisplayed()
composeTestRule.onNodeWithText("Sign In").assertIsDisplayed()
}
@Test
fun shouldShowErrorWhenEmailIsInvalid() {
composeTestRule
.onNodeWithText("Email")
.performTextInput("not-an-email")
composeTestRule
.onNodeWithText("Sign In")
.performClick()
composeTestRule
.onNodeWithText("Please enter a valid email address")
.assertIsDisplayed()
}
}Composable-Only Tests (No Activity Needed)
@RunWith(AndroidJUnit4::class)
class ProductCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldDisplayProductNameAndPrice() {
val product = Product(
id = "1",
name = "Wireless Headphones",
price = 79.99,
inStock = true
)
composeTestRule.setContent {
MaterialTheme {
ProductCard(product = product, onAddToCart = {})
}
}
composeTestRule.onNodeWithText("Wireless Headphones").assertIsDisplayed()
composeTestRule.onNodeWithText("$79.99").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Add to cart").assertIsEnabled()
}
@Test
fun shouldDisableAddToCartWhenOutOfStock() {
val product = Product(
id = "2",
name = "Limited Edition Item",
price = 199.99,
inStock = false
)
composeTestRule.setContent {
MaterialTheme {
ProductCard(product = product, onAddToCart = {})
}
}
composeTestRule
.onNodeWithContentDescription("Add to cart")
.assertIsNotEnabled()
composeTestRule
.onNodeWithText("Out of Stock")
.assertIsDisplayed()
}
}Semantic Tree Assertions
Finding Nodes
@Test
fun semanticNodeFinderExamples() {
composeTestRule.setContent {
MaterialTheme {
Column {
Text("Title", style = MaterialTheme.typography.headlineMedium)
Text("Subtitle", style = MaterialTheme.typography.bodyMedium)
Button(onClick = {}) { Text("Click Me") }
Checkbox(checked = true, onCheckedChange = {})
TextField(
value = "test",
onValueChange = {},
label = { Text("Input Label") }
)
}
}
}
// By text
composeTestRule.onNodeWithText("Title").assertExists()
// By content description
composeTestRule.onNodeWithContentDescription("Checkbox").assertIsChecked()
// By tag (add Modifier.testTag("my-tag") to composable)
composeTestRule.onNodeWithTag("my-tag").assertExists()
// By semantic role
composeTestRule.onNode(hasRole(Role.Button)).assertIsDisplayed()
// Combined matchers
composeTestRule.onNode(
hasText("Click Me") and hasRole(Role.Button)
).assertIsEnabled()
// All nodes matching a predicate
composeTestRule.onAllNodesWithTag("list-item").assertCountEquals(5)
}State Assertions
@Test
fun shouldToggleCheckboxState() {
var isChecked by mutableStateOf(false)
composeTestRule.setContent {
Checkbox(
checked = isChecked,
onCheckedChange = { isChecked = it },
modifier = Modifier.testTag("my-checkbox")
)
}
composeTestRule
.onNodeWithTag("my-checkbox")
.assertIsOff()
composeTestRule
.onNodeWithTag("my-checkbox")
.performClick()
composeTestRule
.onNodeWithTag("my-checkbox")
.assertIsOn()
}
@Test
fun shouldScrollToItemInLazyColumn() {
val items = (1..100).map { "Item $it" }
composeTestRule.setContent {
LazyColumn(modifier = Modifier.testTag("list")) {
items(items) { item ->
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
// Scroll to last item
composeTestRule
.onNodeWithTag("list")
.performScrollToIndex(99)
composeTestRule.onNodeWithText("Item 100").assertIsDisplayed()
}Testing Click Callbacks
@Test
fun shouldInvokeCallbackOnButtonClick() {
var clickCount = 0
composeTestRule.setContent {
MaterialTheme {
Button(
onClick = { clickCount++ },
modifier = Modifier.testTag("increment-button")
) {
Text("Click Me")
}
}
}
repeat(3) {
composeTestRule.onNodeWithTag("increment-button").performClick()
}
assertEquals(3, clickCount)
}Testing Animations
Animations can make tests flaky if not handled properly. Compose provides ways to control animation state:
Disabling Animations in Tests
@get:Rule
val composeTestRule = createComposeRule().apply {
mainClock.autoAdvance = false // Manual clock control
}
@Test
fun shouldShowLoadingThenContent() {
var isLoading by mutableStateOf(true)
composeTestRule.setContent {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.testTag("loading"))
} else {
Text("Content Loaded", modifier = Modifier.testTag("content"))
}
}
composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
composeTestRule.onNodeWithTag("content").assertDoesNotExist()
// Simulate load completion
isLoading = false
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("loading").assertDoesNotExist()
composeTestRule.onNodeWithTag("content").assertIsDisplayed()
}Testing Animated Visibility
@Test
fun shouldAnimateVisibilityCorrectly() {
var visible by mutableStateOf(false)
composeTestRule.setContent {
AnimatedVisibility(visible = visible) {
Text("Animated Content", modifier = Modifier.testTag("animated-content"))
}
}
// Initial state: not visible
composeTestRule.onNodeWithTag("animated-content").assertDoesNotExist()
// Show with animation
visible = true
// Advance clock to complete animation
composeTestRule.mainClock.advanceTimeBy(500L)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("animated-content").assertIsDisplayed()
}animationsDisabled Configuration
@RunWith(AndroidJUnit4::class)
class AnimationTest {
// Create rule with animations disabled for stable tests
@get:Rule
val composeTestRule = createComposeRule()
@Before
fun disableAnimations() {
// Disable animations via SemanticsProperties or test rule
composeTestRule.mainClock.autoAdvance = true
}
@Test
fun dialogShouldAppearImmediately() {
var showDialog by mutableStateOf(false)
composeTestRule.setContent {
if (showDialog) {
AlertDialog(
onDismissRequest = { showDialog = false },
title = { Text("Confirm Action") },
text = { Text("Are you sure?") },
confirmButton = {
Button(onClick = { showDialog = false }) { Text("Yes") }
},
dismissButton = {
Button(onClick = { showDialog = false }) { Text("No") }
}
)
}
Button(onClick = { showDialog = true }) { Text("Show Dialog") }
}
composeTestRule.onNodeWithText("Show Dialog").performClick()
composeTestRule.waitForIdle()
// With autoAdvance=true, dialog appears without manual clock advancement
composeTestRule.onNodeWithText("Are you sure?").assertIsDisplayed()
composeTestRule.onNodeWithText("Yes").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Are you sure?").assertDoesNotExist()
}
}Screenshot Testing
Screenshot testing catches visual regressions that semantic assertions miss:
Using Paparazzi for Compose Screenshot Tests
// build.gradle.kts
dependencies {
testImplementation("app.cash.paparazzi:paparazzi:1.3.4")
}@RunWith(JUnit4::class)
class ProductCardScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "android:Theme.Material3.DayNight.NoActionBar"
)
@Test
fun productCard_inStock() {
paparazzi.snapshot {
MaterialTheme {
ProductCard(
product = Product("1", "Wireless Headphones", 79.99, true),
onAddToCart = {}
)
}
}
}
@Test
fun productCard_outOfStock() {
paparazzi.snapshot {
MaterialTheme {
ProductCard(
product = Product("2", "Limited Edition", 199.99, false),
onAddToCart = {}
)
}
}
}
@Test
fun productCard_darkTheme() {
paparazzi.snapshot {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface {
ProductCard(
product = Product("3", "Premium Item", 299.99, true),
onAddToCart = {}
)
}
}
}
}
}Using AndroidX Screenshot Library
@RunWith(AndroidJUnit4::class)
class ComposeScreenshotTest {
@get:Rule
val screenshotRule = AndroidXScreenshotTestRule("screenshots/compose")
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun loginScreen_emptyState() {
composeTestRule.setContent {
MaterialTheme {
LoginScreen(onLoginSuccess = {})
}
}
screenshotRule.assertBitmapAgainstGolden(
composeTestRule.onRoot().captureToImage().asAndroidBitmap(),
"login_screen_empty"
)
}
}Testing with ViewModels
@RunWith(AndroidJUnit4::class)
class ProductListTest {
@get:Rule
val composeTestRule = createComposeRule()
private val testViewModel = FakeProductViewModel()
@Test
fun shouldShowLoadingState() {
testViewModel.setLoading()
composeTestRule.setContent {
ProductListScreen(viewModel = testViewModel)
}
composeTestRule.onNodeWithTag("loading-indicator").assertIsDisplayed()
composeTestRule.onNodeWithTag("product-list").assertDoesNotExist()
}
@Test
fun shouldShowProductsAfterLoad() {
testViewModel.setProducts(listOf(
Product("1", "Product A", 9.99, true),
Product("2", "Product B", 19.99, true)
))
composeTestRule.setContent {
ProductListScreen(viewModel = testViewModel)
}
composeTestRule.onNodeWithText("Product A").assertIsDisplayed()
composeTestRule.onNodeWithText("Product B").assertIsDisplayed()
composeTestRule.onAllNodesWithTag("product-item").assertCountEquals(2)
}
@Test
fun shouldShowErrorState() {
testViewModel.setError("Network error")
composeTestRule.setContent {
ProductListScreen(viewModel = testViewModel)
}
composeTestRule.onNodeWithText("Network error").assertIsDisplayed()
composeTestRule.onNodeWithText("Retry").assertIsDisplayed()
}
}Testing Navigation
@RunWith(AndroidJUnit4::class)
class NavigationTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun shouldNavigateToDetailOnItemClick() {
// Start on list screen
composeTestRule.onNodeWithTag("product-list").assertIsDisplayed()
// Click first item
composeTestRule
.onAllNodesWithTag("product-item")
.onFirst()
.performClick()
// Should navigate to detail screen
composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("product-detail").assertIsDisplayed()
}
@Test
fun shouldNavigateBackFromDetail() {
// Navigate to detail
composeTestRule.onAllNodesWithTag("product-item").onFirst().performClick()
composeTestRule.waitForIdle()
// Press back
composeTestRule.activityRule.scenario.onActivity { activity ->
activity.onBackPressedDispatcher.onBackPressed()
}
composeTestRule.waitForIdle()
// Should be back on list
composeTestRule.onNodeWithTag("product-list").assertIsDisplayed()
}
}Compose Testing with HelpMeTest
For testing the full mobile web experience of your app in a browser (e.g., a React Native web or PWA build), HelpMeTest provides end-to-end UI testing:
*** Test Cases ***
Product Listing Loads On Mobile Viewport
Go To https://app.example.com/products
Set Window Size 390 844 # iPhone 15 Pro dimensions
Wait Until Element Is Visible [data-testid=product-list]
Element Count Should Be Greater Than [data-testid=product-item] 0
Check For Visual FlawsWhile HelpMeTest targets web UIs, it complements Compose tests by validating the backend APIs and web interfaces your Android app depends on.
Common Pitfalls
1. Using hardcoded string literals in test assertions Always use string resources via composeTestRule.activity.getString(R.string.button_label) instead of hardcoded strings. This prevents test failures when copy is updated.
2. Not waiting for idle state After actions that trigger async operations (navigation, data loading), always call composeTestRule.waitForIdle() before asserting.
3. Testing implementation details Avoid onNodeWithTag for every element—use meaningful semantic properties like hasText, hasContentDescription, or hasRole. Tags are implementation details; semantics are contracts.
4. Ignoring the accessibility tree The semantic tree IS your accessibility tree. Tests that pass semantic assertions are simultaneously validating accessibility—a two-for-one benefit.
Summary
Jetpack Compose testing provides a clean, semantics-based API:
createComposeRule()for composable-only tests,createAndroidComposeRule<Activity>()for integration tests- Semantic finders (
hasText,hasRole,hasContentDescription) for resilient node selection - State assertions to verify checked, enabled, focused, and displayed states
- Animation control via
mainClockfor deterministic animation tests - Paparazzi for fast, JVM-based screenshot testing without a device
- ViewModel integration for testing loading, error, and success states
The semantic tree approach makes Compose tests more readable and more resistant to UI refactoring than View-based Espresso tests.