Jetpack Compose Testing Guide: ComposeTestRule, Semantic Tree Assertions & Screenshot Testing

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 Flaws

While 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 mainClock for 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.

Read more