How to write tests in Jetpack Compose

Testing composables with semantics, assertions, and UI automation in Compose

, updated

Compose has a first-class testing framework that lets you verify UI behavior without needing an emulator.

Add test dependencies

dependencies {
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
    androidTestImplementation("androidx.test.ext:junit:1.2.0")
    androidTestImplementation("androidx.test:runner:1.6.0")
}

Basic composable test

class CounterTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun counterStartsAtZero() {
        composeTestRule.setContent {
            Counter()
        }

        composeTestRule.onNodeWithText("Count: 0")
            .assertExists()
            .assertIsDisplayed()
    }

    @Test
    fun counterIncrementsOnTap() {
        composeTestRule.setContent {
            Counter()
        }

        composeTestRule.onNodeWithText("Increment")
            .performClick()

        composeTestRule.onNodeWithText("Count: 1")
            .assertExists()
    }
}

Finding nodes (semantic matchers)

// By text
composeTestRule.onNodeWithText("Hello")

// By content description (accessibility)
composeTestRule.onNodeWithContentDescription("Add item")

// By test tag
composeTestRule.onNodeWithTag("submitButton")

// By semantic properties
composeTestRule.onNode(
    hasText("Hello") and hasClickAction()
)

// With SemanticsModifier
@Composable
fun MyButton(modifier: Modifier = Modifier) {
    Button(
        onClick = { /* ... */ },
        modifier = modifier.testTag("myButton")
    ) {
        Text("Submit")
    }
}

Assertions

// Exists and is displayed
composeTestRule.onNodeWithText("Hello").assertExists()
composeTestRule.onNodeWithText("Hello").assertIsDisplayed()

// Not displayed
composeTestRule.onNodeWithText("Loading").assertDoesNotExist()

// Enabled / disabled
composeTestRule.onNodeWithText("Submit").assertIsEnabled()
composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()

// Selected / not selected
composeTestRule.onNodeWithTag("checkbox").assertIsSelected()
composeTestRule.onNodeWithTag("checkbox").assertIsNotSelected()

// Has click action
composeTestRule.onNodeWithText("Login").assert(hasClickAction())

// Matches a custom condition
composeTestRule.onNodeWithTag("price")
    .assert(SemanticsMatcher("text contains $") { node ->
        node.config.get(SemanticsProperties.Text).any { it.contains("$") }
    })

Actions

// Click
composeTestRule.onNodeWithText("Submit").performClick()

// Type text
composeTestRule.onNodeWithText("Email").performTextInput("[email protected]")

// Replace text
composeTestRule.onNodeWithText("Email").performTextReplacement("[email protected]")

// Clear text
composeTestRule.onNodeWithText("Email").performTextClearance()

// Scroll
composeTestRule.onNodeWithText("Item 50").performScrollTo()

// Swipe (on swipe-to-dismiss)
composeTestRule.onNodeWithTag("dismissibleItem")
    .performTouchInput { swipeLeft() }

// Long press
composeTestRule.onNodeWithText("Item").performTouchInput { longClick() }

// Gesture sequence
composeTestRule.onNodeWithTag("canvas").performTouchInput {
    down(center)
    moveTo(Offset(center.x, center.y + 100f))
    up()
}

Testing with ViewModel

Use hilt or manually provide a test ViewModel:

@Test
fun userListDisplaysUsers() {
    val fakeRepository = FakeUserRepository(listOf(User("1", "Alice")))
    val viewModel = UserViewModel(fakeRepository)

    composeTestRule.setContent {
        MyTheme {
            UserListScreen(viewModel = viewModel)
        }
    }

    composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
}

Testing navigation

@Test
fun navigationToDetails() {
    val navController = TestNavController()
    composeTestRule.setContent {
        AppNavigation(navController = navController)
    }

    composeTestRule.onNodeWithText("View Details").performClick()

    assertThat(navController.currentDestination?.route).isEqualTo("details/{itemId}")
}

Waiting for async content

// Wait until a node appears
composeTestRule.waitUntil(
    timeoutMillis = 5000L,
    condition = {
        composeTestRule.onAllNodesWithText("Loaded")
            .fetchSemanticsNodes()
            .isNotEmpty()
    }
)

// Wait until idle (all animations, recompositions complete)
composeTestRule.waitForIdle()

Run tests

# Run all Compose tests
./gradlew connectedAndroidTest

# Run a specific test class
./gradlew connectedAndroidTest --tests "com.example.CounterTest"

# Run on a specific device
adb devices
./gradlew -Pandroid.connectedDevice=<serial> connectedAndroidTest