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