Testing and accessibility questions show up in senior-level Compose interviews because they reveal whether youâve actually shipped production Compose code. Anyone can write UI â verifying it works and making it usable for everyone is what separates strong candidates.
ComposeTestRule is the test rule that manages the Compose test environment. It lets you set content, find nodes, make assertions, and perform actions. You create it with createComposeRule() for standalone Compose tests, or createAndroidComposeRule<YourActivity>() when you need an Activity.
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_disabledWhenFieldsEmpty() {
composeTestRule.setContent {
LoginScreen(onLogin = {})
}
composeTestRule
.onNodeWithText("Login")
.assertIsNotEnabled()
}
}
createComposeRule() creates a blank Compose host without an Activity. Itâs faster and preferred for testing individual composables. Use createAndroidComposeRule only when your composable depends on Activity-level resources like themes or permissions.
createComposeRule() launches a minimal ComponentActivity under the hood. Itâs lightweight, fast, and enough for testing composables in isolation. You call setContent {} directly on the rule.
createAndroidComposeRule<YourActivity>() launches a specific Activity. Use it when your composable depends on something the Activity provides â a specific theme, injected dependencies, permissions, or CompositionLocal values that the Activity sets up. It gives you access to the Activity instance via composeTestRule.activity.
The tradeoff is speed. createAndroidComposeRule has more startup overhead because it launches a full Activity. For most composable tests, createComposeRule is the right choice.
Node finders query the semantic tree to locate composable nodes. onNode takes a raw SemanticsMatcher and is the most flexible. onNodeWithText, onNodeWithContentDescription, and onNodeWithTag are convenience wrappers with pre-built matchers.
// These are equivalent
composeTestRule.onNode(hasText("Submit"))
composeTestRule.onNodeWithText("Submit")
// Find by test tag
composeTestRule.onNodeWithTag("submit_button")
// Find by content description
composeTestRule.onNodeWithContentDescription("Close dialog")
// Find multiple nodes
composeTestRule.onAllNodesWithText("Item")
.assertCountEquals(5)
onNode returns a SemanticsNodeInteraction which you chain with assertions or actions. If no node matches, the assertion fails with a clear error showing the current semantic tree.
Assertions verify node properties. Actions simulate user input.
Common assertions:
assertIsDisplayed() â node exists and is visible on screenassertIsNotDisplayed() â node exists but isnât visibleassertExists() â node is in the tree, may or may not be visibleassertDoesNotExist() â node is not in the tree at allassertIsEnabled() / assertIsNotEnabled()assertTextEquals("expected text")assertHasClickAction()Common actions:
performClick()performTextInput("text")performTextClearance()performScrollTo() â scrolls until node is visibleperformTouchInput { swipeLeft() }The difference between assertIsDisplayed and assertExists matters. A node inside a LazyColumn scrolled off screen exists in the semantic tree but isnât displayed. An AnimatedVisibility with visible = false removes the node entirely after the exit animation, so assertDoesNotExist() is the right check there.
The semantic tree is a parallel tree that Compose builds alongside the UI tree. It describes what each composable means rather than how it looks. Every composable with semantic properties â text, click actions, content descriptions, roles â creates a node in this tree.
Compose tests donât interact with pixels or layout coordinates. They query the semantic tree. This is why you need Modifier.testTag("myTag") or Modifier.semantics { contentDescription = "close" } to make composables findable in tests. A plain Box with no semantics is invisible to the test framework.
You can print the tree for debugging:
composeTestRule.onRoot().printToLog("SEMANTIC_TREE")
The semantic tree also powers accessibility services like TalkBack. When you write tests that assert on semantics, youâre also verifying that your UI is accessible.
Modifier.testTag("tag") adds a test tag to a composableâs semantics, making it findable via onNodeWithTag("tag") in tests. Use it when a composable doesnât have natural semantic identifiers like text or content descriptions.
LazyColumn(modifier = Modifier.testTag("task_list")) {
items(tasks) { task ->
TaskRow(
task = task,
modifier = Modifier.testTag("task_${task.id}")
)
}
}
// In test
composeTestRule
.onNodeWithTag("task_list")
.assertIsDisplayed()
composeTestRule
.onNodeWithTag("task_42")
.performClick()
Donât overuse test tags. If a composable already has text or a content description, prefer onNodeWithText or onNodeWithContentDescription because those also verify that the content is correct. Test tags are a fallback for elements like containers, dividers, or icons without text.
For regular scrollable columns, use performScrollTo() which scrolls until the target node is visible:
composeTestRule
.onNodeWithText("Terms and Conditions")
.performScrollTo()
.assertIsDisplayed()
For LazyColumn, items off-screen donât exist in the semantic tree until theyâre composed. You canât find a node that hasnât been created yet. Use performScrollToIndex or performScrollToKey:
composeTestRule
.onNodeWithTag("task_list")
.performScrollToIndex(25)
composeTestRule
.onNodeWithTag("task_list")
.performScrollToKey("task_42")
performScrollToKey requires that you set key in your LazyColumnâs items block. Stable keys matter for testability, not just animation and performance.
Compose tests auto-synchronize with the Compose frame clock â they wait for pending recompositions and animations before executing the next assertion. But they donât wait for coroutines, network calls, or other async work outside the Compose framework.
For those cases, use waitUntil:
@Test
fun searchResults_appearAfterLoading() {
composeTestRule.setContent {
SearchScreen(viewModel = searchViewModel)
}
composeTestRule
.onNodeWithTag("search_input")
.performTextInput("kotlin")
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithTag("search_result")
.fetchSemanticsNodes()
.isNotEmpty()
}
composeTestRule
.onAllNodesWithTag("search_result")
.assertCountEquals(3)
}
waitUntil polls the condition repeatedly until it returns true or the timeout expires. Thereâs also waitForIdle() which waits until Compose is idle (no pending recompositions), but it wonât help if youâre waiting for a ViewModel to emit new state from a coroutine.
The preferred approach is making composables take state and callbacks as parameters rather than a ViewModel directly. This makes them easy to test in isolation without any mocking:
@Composable
fun TaskListScreen(
tasks: List<Task>,
onTaskClick: (Task) -> Unit,
onDelete: (Task) -> Unit
) { /* ... */ }
@Test
fun taskList_displaysAllTasks() {
val fakeTasks = listOf(
Task(id = 1, title = "Write tests"),
Task(id = 2, title = "Fix bug")
)
composeTestRule.setContent {
TaskListScreen(
tasks = fakeTasks,
onTaskClick = {},
onDelete = {}
)
}
composeTestRule.onNodeWithText("Write tests").assertIsDisplayed()
composeTestRule.onNodeWithText("Fix bug").assertIsDisplayed()
}
If you must test with a ViewModel, use a fake or provide fake dependencies through Hiltâs TestInstallIn. But the more your composable depends on a ViewModel directly, the harder it is to test. State hoisting isnât just an architecture pattern â itâs a testability pattern.
A few patterns that trip people up:
waitUntil or advance the test dispatcher.Thread.sleep in Compose tests. Use waitUntil, advanceTimeBy on the test clock, or TestDispatcher for coroutine timing.// Bad â flaky
Thread.sleep(2000)
composeTestRule.onNodeWithText("Loaded").assertIsDisplayed()
// Good â deterministic
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule
.onAllNodesWithText("Loaded")
.fetchSemanticsNodes()
.isNotEmpty()
}
You test Compose Navigation by creating a TestNavHostController and asserting on the current route after user actions.
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
@Test
fun clickingProfile_navigatesToProfileScreen() {
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
AppNavGraph(navController = navController)
}
composeTestRule
.onNodeWithText("Profile")
.performClick()
val route = navController.currentBackStackEntry
?.destination?.route
assertEquals("profile", route)
}
}
The key is injecting the TestNavHostController so you can inspect the back stack. Test the navigation outcome (which screen am I on?) rather than implementation details. If youâre testing deep links, call navController.navigate("deep/link/path") and assert the correct screen content appears.
Hilt requires a custom test rule because it needs to inject dependencies before your composable renders. Use createAndroidComposeRule with a HiltTestActivity and annotate the test class with @HiltAndroidTest.
@HiltAndroidTest
class TaskScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<HiltTestActivity>()
@Test
fun taskScreen_showsTasks() {
composeTestRule.setContent {
TaskScreen()
}
composeTestRule
.onNodeWithText("My Tasks")
.assertIsDisplayed()
}
}
Rule ordering matters â HiltAndroidRule must run before ComposeTestRule so dependencies are injected first. You also need to register HiltTestActivity in your test manifest. For swapping dependencies in tests, use @TestInstallIn to replace production modules with test modules that provide fakes.
Semantics are metadata attached to composables that describe their meaning and behavior. They serve two purposes: powering the test framework and powering accessibility services like TalkBack.
When you set contentDescription, role, stateDescription, or other semantic properties, youâre telling both the test framework and accessibility services what a composable represents. A custom toggle without semantics is invisible to TalkBack â a sighted user can interact with it, but a visually impaired user cannot.
@Composable
fun FavoriteToggle(isFavorite: Boolean, onToggle: () -> Unit) {
IconButton(
onClick = onToggle,
modifier = Modifier.semantics {
contentDescription = if (isFavorite)
"Remove from favorites" else "Add to favorites"
role = Role.Switch
stateDescription = if (isFavorite) "Active" else "Inactive"
}
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite
else Icons.Outlined.FavoriteBorder,
contentDescription = null
)
}
}
Setting contentDescription = null on the Icon is intentional. The parent IconButton already provides a label through its semantics. If both had content descriptions, TalkBack would announce them both, which is confusing.
contentDescription tells accessibility services what a visual element represents. For Image and Icon, itâs a direct parameter. For other composables, use Modifier.semantics.
The rules are straightforward:
contentDescription = null. TalkBack skips them.Text("Settings") and a gear icon, the iconâs content description should be null.// Decorative â skip
Icon(Icons.Filled.Star, contentDescription = null)
// Informational â describe
Image(
painter = painterResource(R.drawable.profile),
contentDescription = "Profile photo of ${user.name}"
)
// Interactive â action-oriented
IconButton(onClick = onDelete) {
Icon(Icons.Filled.Delete, contentDescription = "Delete message")
}
Compose maintains two views of the semantic tree. The merged tree combines semantics from parent and children into single nodes. A Button containing Text("Hello") and Text("World") appears as one node with text [Hello, World] in the merged tree. This is what TalkBack sees and what tests query by default.
The unmerged tree preserves every individual node. The same Button shows as a parent with MergeDescendants = true and two separate child text nodes.
// Merged tree (default)
composeTestRule
.onNodeWithText("Hello")
.assertIsDisplayed()
// Unmerged tree â finds the individual Text node
composeTestRule
.onNodeWithText("Hello", useUnmergedTree = true)
.assertIsDisplayed()
You need useUnmergedTree = true when testing specific child elements inside a merged parent. For example, verifying a list itemâs subtitle when the title and subtitle are merged into one semantic node. Merging is controlled by Modifier.semantics(mergeDescendants = true). Clickable composables and Buttons merge by default because TalkBack should treat them as single interactive units.
Roles tell accessibility services what kind of component an element is. TalkBack uses roles to decide the announcement â âdouble tap to toggleâ for switches, âdouble tap to activateâ for buttons. Standard Material composables like Button, Checkbox, and Switch set roles automatically. You set them manually for custom components.
Available roles: Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, and Range.
@Composable
fun CustomSwitch(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Box(
modifier = Modifier
.toggleable(
value = checked,
onValueChange = onCheckedChange,
role = Role.Switch
)
.semantics {
contentDescription = "Dark mode"
stateDescription = if (checked) "On" else "Off"
}
.size(52.dp, 28.dp)
.background(
if (checked) Color.Blue else Color.Gray,
RoundedCornerShape(14.dp)
)
) { /* thumb */ }
}
Pass role through the interaction modifier (clickable, toggleable, selectable) rather than setting it separately in Modifier.semantics. The interaction modifier merges it into the semantics correctly.
Traversal order controls the sequence TalkBack uses to navigate through elements. By default, Compose follows a top-to-bottom, start-to-end reading order based on layout. But sometimes visual order doesnât match logical order, especially with overlapping elements or custom layouts.
Use Modifier.semantics { traversalIndex = N } to override. Lower values come first:
@Composable
fun HeaderWithAction() {
Box {
IconButton(
onClick = { /* settings */ },
modifier = Modifier
.align(Alignment.TopEnd)
.semantics { traversalIndex = 2f }
) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
Text(
text = "Welcome back",
modifier = Modifier
.align(Alignment.TopStart)
.semantics { traversalIndex = 0f }
)
Text(
text = "Here are your tasks",
modifier = Modifier
.align(Alignment.CenterStart)
.semantics { traversalIndex = 1f }
)
}
}
isTraversalGroup = true on a parent tells TalkBack to finish all children inside the group before moving to the next sibling group. This is important when elements from different groups are visually interleaved.
Live regions tell accessibility services to announce content changes automatically, without the user navigating to the element. Use them for dynamic content that updates in place â error messages, countdown timers, loading status, or toast-like notifications.
@Composable
fun FormField(error: String?) {
Column {
TextField(value = input, onValueChange = { input = it })
if (error != null) {
Text(
text = error,
color = Color.Red,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
}
)
}
}
}
There are two modes:
Without liveRegion, TalkBack wonât announce the error text unless the user navigates to it manually. Thatâs a broken accessibility experience.
You test accessibility by asserting on semantic properties â the same properties that TalkBack uses. If your tests verify semantics, youâre simultaneously verifying accessibility.
@Test
fun favoriteButton_hasCorrectAccessibility() {
composeTestRule.setContent {
FavoriteToggle(isFavorite = true, onToggle = {})
}
composeTestRule
.onNodeWithContentDescription("Remove from favorites")
.assertIsDisplayed()
.assert(hasRole(Role.Switch))
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.StateDescription, "Active"
)
)
}
@Test
fun errorMessage_hasLiveRegion() {
composeTestRule.setContent {
FormField(error = "Email is required")
}
composeTestRule
.onNodeWithText("Email is required")
.assert(
SemanticsMatcher.expectValue(
SemanticsProperties.LiveRegion, LiveRegionMode.Polite
)
)
}
The pattern is: define semantics in your composable, then verify them in tests. This creates a contract â if someone removes the content description or changes the role, the test fails. Without these tests, accessibility regressions go unnoticed because nobody manually tests with TalkBack on every change.
Screenshot testing captures a visual snapshot of your UI and compares it against a saved reference image. If the pixels differ beyond a threshold, the test fails. This catches visual regressions that semantic-based tests miss â wrong colors, broken layouts, clipped text.
Paparazzi (by Cash App) runs screenshot tests on the JVM without an emulator. It uses layoutlib â the same rendering engine Android Studio uses for previews â to render composables into bitmaps.
class ProfileCardScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6,
theme = "Theme.Material3.DayNight"
)
@Test
fun profileCard_default() {
paparazzi.snapshot {
ProfileCard(
name = "Mukul Jangra",
role = "Senior Android Engineer"
)
}
}
}
The first run generates reference images. Subsequent runs compare against them. You commit reference images to version control. The big advantage over on-device screenshot tests is speed â tests run in seconds as JVM unit tests. The tradeoff is that layoutlib isnât pixel-perfect compared to a real device, so some device-specific rendering differences wonât be caught.
Compose Preview testing reuses @Preview composables as test inputs instead of writing separate test setup. Tools like Paparazzi and Googleâs Compose Preview Screenshot Testing library can render previews and compare them against reference images.
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
@Composable
fun ProfileCard_Preview() {
AppTheme {
ProfileCard(
name = "Mukul Jangra",
role = "Senior Android Engineer"
)
}
}
Googleâs compose-preview-screenshot-testing library lets you annotate Preview composables and generate screenshot tests from them automatically. Your previews serve triple duty â visual feedback during development, screenshot test inputs, and documentation.
The limitation is that previews only test visual output. They canât verify interactions, navigation, or state changes. Use previews for visual regression testing and ComposeTestRule for behavioral testing.
assertIsDisplayed and assertExists for off-screen LazyColumn items?CompositionLocal values?TestDispatcher to control coroutine timing in Compose tests?