Upgrade to Pro — share decks privately, control downloads, hide ads and more …

How to Test Your Compose UI

How to Test Your Compose UI

Jetpack Compose is the new and shiny framework that can finally replace XMLs and the View system in Android projects. While Compose is easy to adopt, not creating legacy code right at the start of such a journey requires some extra planning, awareness, and testing. In this talk, you'll have a quick look at how you can set up, write, and run Compose UI tests and how to write testable composables.

This talk was presented at the Kotlin Budapest Meetup, 2022.05.03.
https://www.meetup.com/Kotlin-Budapest/events/285101524/

E8168a08862631072b2e82e7b662dc07?s=128

István Juhos

May 03, 2022
Tweet

More Decks by István Juhos

Other Decks in Technology

Transcript

  1. stewemetal How to Test Your Compose UI István Juhos

  2. Kotlin Budapest Meetup – @stewemetal Source of examples https:!//github.com/stewemetal/composehydrationtracker

  3. Kotlin Budapest Meetup – @stewemetal Testing Views - Recap ☕

    • ViewGroup and View objects with rendering and behavior • Views are inflated from XML descriptors or instantiated in code • Views are directly referrable by View IDs
  4. Kotlin Budapest Meetup – @stewemetal Testing Views - Recap ☕

    <androidx.recyclerview.widget.RecyclerView android:id="@+id/entryList" android:layout_width="match_parent" android:layout_height="wrap_content" app:layoutManager="LinearLayoutManager" tools:listitem="@layout/item_entry" tools:itemCount=”5" !/> <LinearLayout !!...> <TextView android:id="@+id/amount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" tools:text="100 ml" !/> <TextView android:id="@+id/date" android:layout_width="wrap_content" android:layout_height="wrap_content" tools:text="2022-05-03" !/> !</LinearLayout>
  5. Kotlin Budapest Meetup – @stewemetal Testing Views - Recap ☕

    @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule var activityScenarioRule = activityScenarioRule<MainActivity>() @Test fun testList() { onView(withId(R.id.entryList)) .perform(!!...) } }
  6. Kotlin Budapest Meetup – @stewemetal Testing Views - Recap ☕

    @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule var activityScenarioRule = activityScenarioRule<MainActivity>() @Test fun testList() { onView(withId(R.id.entryList)) .perform(!!...) } }
  7. Kotlin Budapest Meetup – @stewemetal Testing Compose UI • No

    direct access to the Composition • No UI objects to look up • No IDs • Not every Composable emits UI
  8. Kotlin Budapest Meetup – @stewemetal Testing Compose UI 🧪

  9. Kotlin Budapest Meetup – @stewemetal Dependencies androidTestImplementation "androidx.compose.ui:ui-test:$compose_version" !// Test

    rules and transitive dependencies: androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" !// Needed for createComposeRule, but not createAndroidComposeRule: debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
  10. Kotlin Budapest Meetup – @stewemetal @Composable fun EntryList( entries: List<HydrationEntry>

    = emptyList(), ) { LazyColumn { items(entries) { item -> HydrationItem(item = item) } } } @Composable fun HydrationItem(item: HydrationEntry) { Row( modifier = Modifier .wrapContentHeight( align = Alignment.CenterVertically, Composables to test: EntryList
  11. Kotlin Budapest Meetup – @stewemetal HydrationItem(item = item) } }

    } @Composable fun HydrationItem(item: HydrationEntry) { Row( modifier = Modifier .wrapContentHeight( align = Alignment.CenterVertically, ) .padding(16.dp) ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text("${item.dateTime}") } } Composables to test: HydrationItem
  12. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule val composeTestRule = !// !!... } createComposeRule()
  13. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule val composeTestRule = !// !!... } createComposeRule()
  14. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule val composeTestRule = !// ComposeContentTestRule !// !!... } createComposeRule()
  15. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule val composeTestRule = !// !!... } create ComposeRule () Android <MainActivity>
  16. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule val composeTestRule = !// !!... } create ComposeRule Android <ComponentActivity>()
  17. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @RunWith(AndroidJUnit4!::class) class EntryListTest { @get:Rule val composeTestRule = !// !!... } createComposeRule()
  18. Kotlin Budapest Meetup – @stewemetal @get:Rule val composeTestRule = createComposeRule()

    @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } } The anatomy of Compose UI tests
  19. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } }
  20. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } }
  21. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } }
  22. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } }
  23. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } } private val entries = listOf( HydrationEntry(…), HydrationEntry(…), HydrationEntry(…), )
  24. Kotlin Budapest Meetup – @stewemetal The anatomy of Compose UI

    tests @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } } So, what can we do here without view IDs? 🤔
  25. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳

  26. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 •

    A parallel tree next to the Composition 🌲🌳 • Used for accessibility and testing • Composables (can) contribute to the semantics tree
  27. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 composeTestRule.apply

    { setContent { Text("100 ml") } } Semantics tree root node Semantic node with text "100 ml"
  28. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 composeTestRule.apply

    { setContent { Text("100 ml") } } Root Text = '[100 ml]'
  29. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 composeTestRule.apply

    { setContent { Row { Text("100 ml") } } } Root Text = '[100 ml]'
  30. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 composeTestRule.apply

    { setContent { Row { Text("100 ml") Text("2022-05-03") } } } Text = '[100 ml]' Text = '[2022-05-03]' Root
  31. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 •

    We can affect how the semantics tree is built Row( modifier = Modifier .semantics { !// !!... } ) { Text("100 ml") Text("2022-05-03") }
  32. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 •

    We can affect how the semantics tree is built Row( modifier = Modifier .semantics { !// !!... } ) { Text("100 ml") Text("2022-05-03") }
  33. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 •

    We can affect how the semantics tree is built Row( modifier = Modifier .semantics { !// !!... } ) { Text("100 ml") Text("2022-05-03") } Root Text = '[100 ml]' Text = '[2022-05-03]'
  34. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 •

    We can affect how the semantics tree is built Row( modifier = Modifier .semantics { contentDescription = "List item" } ) { Text("100 ml") Text("2022-05-03") } Root Text = '[100 ml]' Text = '[2022-05-03]' ContentDescription = '[List item]'
  35. Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳 •

    We can affect how the semantics tree is built Row( modifier = Modifier .semantics { testTag = "List item" } ) { Text("100 ml") Text("2022-05-03") } Root Text = '[100 ml]' Text = '[2022-05-03]' Tag = '[List item]'
  36. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    Text = '[100 ml]' Text = '[2022-05-03]' Root Row { Text("100 ml") Text("2022-05-03") }
  37. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Compose tests • printToLog("TAG") Text = '[100 ml]' Text = '[2022-05-03]' Root
  38. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Compose tests onRoot() .printToLog("RowAndTexts") Text = '[100 ml]' Text = '[2022-05-03]' Root
  39. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Compose tests onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-05-03]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-05-03]' Root
  40. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Compose tests • printToLog("TAG") onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-05-03]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-05-03]' Root
  41. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Compose tests • printToLog("TAG") onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-05-03]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-05-03]' Root
  42. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Compose tests • printToLog("TAG") onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-05-03]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-05-03]' Root
  43. Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀

    • In Android Studio • Layout Inspector
  44. Kotlin Budapest Meetup – @stewemetal Merging semantics Row( modifier =

    Modifier .wrapContentHeight( align Alignment.CenterVertically, ) .padding(16.dp) ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } Text = '[2022-05-03]' Text = '[100 ml]' Root =
  45. Kotlin Budapest Meetup – @stewemetal Merging semantics Row( modifier =

    Modifier .wrapContentHeight( align Alignment.CenterVertically, ) .padding(16.dp) .semantics(mergeDescendants = false) {} ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } = Text = '[2022-05-03]' Text = '[100 ml]' Root
  46. Kotlin Budapest Meetup – @stewemetal Merging semantics Text = '[100

    ml, 2022-05-01]' Root Row( modifier = Modifier .wrapContentHeight( align Alignment.CenterVertically, ) .padding(16.dp) .semantics(mergeDescendants = true) {} ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } =
  47. Kotlin Budapest Meetup – @stewemetal Merged semantics by default Button(

    onClick = {}, ) { Text("100 ml") } Role = 'Button' Focused = 'false' Text = '[100 ml]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true'
  48. Kotlin Budapest Meetup – @stewemetal Using the semantics tree!!... @get:Rule

    val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } } !!...we can test Compose UI 🤩
  49. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onRoot onRoot() .printToLog(”TAG") Root Text = '[100 ml, 2022-05-03]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-05-03]'
  50. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onNodeWithTag onNodeWithTag("List item") Root Text = '[100 ml, 2022-05-03]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-05-03]'
  51. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onNodeWithContentDescription onNodeWithContentDescription( "100 ml on 2022-05-03", ) Root Text = '[100 ml, 2022-05-03]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-05-03]'
  52. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onNodeWithText onNodeWithText( "100 ml", ) Root Text = '[100 ml, 2022-05-03]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-05-03]'
  53. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onNodeWithText onNodeWithText( "100 ml", useUnmergedTree = false, ) Root Text = '[100 ml, 2022-05-03]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-05-03]'
  54. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onNodeWithText Root Text = '[100 ml]' Text = '[2022-05-03]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-05-03]' onNodeWithText( "100 ml", useUnmergedTree = true, )
  55. Kotlin Budapest Meetup – @stewemetal Select nodes - Finders •

    onAllNodesWith!!... Tag = '[entries_list]' onAllNodesNodeWithTag("List item") Tag = '[List item]' Text = '[500 ml, 2022-05-03]' Tag = '[List item]' Text = '[100 ml, 2022-04-30]' !!...
  56. Kotlin Budapest Meetup – @stewemetal Actions • performClick • performScrollTo!!...

    • performTextInput • performGesture • performKeyPress • !!... onNodeWithText("100 ml") .performClick()
  57. Kotlin Budapest Meetup – @stewemetal Assertions • assertExists • assertIsDisplayed

    • assertIsEnabled • assertContentDescription • assertTextEquals • assert() with matchers • !!... onNodeWithText("100 ml") .assertIsEnabled()
  58. Kotlin Budapest Meetup – @stewemetal Testing in isolation • Structure

    your composables with testing in mind @Composable fun EntryList( entries: List<HydrationEntry> = emptyList() ) { LazyColumn( modifier = Modifier.testTag("entries_list") ) { items(entries) { item -> HydrationItem(item = item) } } }
  59. Kotlin Budapest Meetup – @stewemetal Testing in isolation • Structure

    your composables with testing in mind @Composable fun EntriesScreen( viewModel: EntriesViewModel = hiltViewModel() ) { when (val uiState = viewModel.uiState) { Loading -> EntriesLoading() is Content -> EntryList(entries = uiState.entries) } }
  60. Kotlin Budapest Meetup – @stewemetal Testing in isolation • Structure

    your composables with testing in mind @Composable fun EntriesScreen( viewModel: EntriesViewModel = hiltViewModel() ) { when (val uiState = viewModel.uiState) { Loading -> EntriesLoading() is Content -> EntryList(entries = uiState.entries) } }
  61. Kotlin Budapest Meetup – @stewemetal Testing in isolation • Structure

    your composables with testing in mind @Composable fun EntriesScreen( viewModel: EntriesViewModel = hiltViewModel() ) { when (val uiState = viewModel.uiState) { Loading -> EntriesLoading() is Content -> EntryList(entries = uiState.entries) } }
  62. Kotlin Budapest Meetup – @stewemetal Testing in isolation composeTestRule.apply {

    setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } onNodeWithTag("entries_list") .onChildren() .assertCountEquals(entries.size) }
  63. Kotlin Budapest Meetup – @stewemetal Testing behavior • Test behavior

    with fake callbacks @Composable fun PredefinedValuesInput( onAddDrink: (Int) -> Unit, ) { Row { Button(onClick = { onAddDrink(100) }) { Text("100 ml") } Button(onClick = { onAddDrink(250) }) { Text("250 ml") } Button(onClick = { onAddDrink(500) }) { Text("500 ml") } } }
  64. Kotlin Budapest Meetup – @stewemetal Testing behavior • Test behavior

    with fake callbacks @Composable fun PredefinedValuesInput( onAddDrink: (Int) -> Unit, ) { Row { Button(onClick = { onAddDrink(100) }) { Text("100 ml") } Button(onClick = { onAddDrink(250) }) { Text("250 ml") } Button(onClick = { onAddDrink(500) }) { Text("500 ml") } } }
  65. Kotlin Budapest Meetup – @stewemetal Testing behavior composeTestRule.apply { var

    addedDrinkValue = 0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }
  66. Kotlin Budapest Meetup – @stewemetal Testing behavior composeTestRule.apply { var

    addedDrinkValue = 0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }
  67. Kotlin Budapest Meetup – @stewemetal Testing behavior composeTestRule.apply { var

    addedDrinkValue = 0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }
  68. Kotlin Budapest Meetup – @stewemetal Topics to check out •

    Testing Compose animations • Testing Views in Composables • Synchronization and IdlingResources
  69. Kotlin Budapest Meetup – @stewemetal Resources • https:!//developer.android.com/jetpack/compose/testing • https:!//developer.android.com/jetpack/compose/testing-

    cheatsheet • https:!//developer.android.com/jetpack/compose/accessibi lity • https:!//developer.android.com/jetpack/compose/semantics • https:!//youtu.be/kdwofTaEHrs • https:!//adbackstage.libsyn.com/episode-171-compose- testing • https:!//github.com/android/compose-samples • https:!//github.com/stewemetal/composehydrationtracker
  70. stewemetal How to Test Your Compose UI István Juhos •

    Instrumented tests with Rules • Testing based on the semantics tree • Planned accessibility = good testability • Implement composables with testability in mind Cover photo by Rob Mulally on Unsplash