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

How to Test Your Compose UI (Droidcon Berlin 2022)

How to Test Your Compose UI (Droidcon Berlin 2022)

While Compose is easy to adopt, not creating legacy code right at the start of such a journey requires some extra planning, awareness, and, most importantly, testing. We'll have a look at how we can test pure Compose UIs as well as hybrid ones that have Views and composables too.

In this talk, we'll learn what the semantics tree is, what is its relation to the composition, how we can manipulate it in composables using the Semantics modifier, how we can implement composables with testability in mind, and how we can test pure Compose and hybrid UIs.

I presented this talk at Droidcon Berlin 2022.

Video of the talk is coming soon.

E8168a08862631072b2e82e7b662dc07?s=128

István Juhos

July 07, 2022
Tweet

More Decks by István Juhos

Other Decks in Programming

Transcript

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

  2. #dcbln22 – @stewemetal Source of examples github.com/stewemetal/composehydrationtracker

  3. #dcbln22 – @stewemetal Testing Views - Recap ☕ • View(Group)

    objects with rendering and behavior • Views are inflated from XML descriptors or instantiated in code • Views are directly referrable by View IDs
  4. #dcbln22 – @stewemetal Testing Compose UI 🧪

  5. #dcbln22 – @stewemetal Testing Compose UI • No direct access

    to the Composition • No UI objects to look up directly • No UI element IDs • Not every Composable emits UI ❌ ❌ ❌ ❌ 🧪
  6. #dcbln22 – @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 #dcbln22 – @stewemetal EntryList HydrationItem
  7. #dcbln22 – @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 #dcbln22 – @stewemetal HydrationItem EntryList
  8. #dcbln22 – @stewemetal Setting up Compose UI tests

  9. #dcbln22 – @stewemetal // 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" Setting up Compose UI tests
  10. #dcbln22 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class

    EntryListTest { // ... } create ComposeRule () Android <MainActivity> @get:Rule val composeTestRule =
  11. #dcbln22 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class

    EntryListTest { // ... } create ComposeRule () Android <MainActivity> @get:Rule val composeTestRule =
  12. #dcbln22 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class

    EntryListTest { // ... } create ComposeRule () Android <ComponentActivity> @get:Rule val composeTestRule =
  13. #dcbln22 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4!::class) class

    EntryListTest { !// !!... } createComposeRule() @get:Rule val composeTestRule =
  14. #dcbln22 – @stewemetal @get:Rule val composeTestRule = createComposeRule() @Test fun

    entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } } A simple UI test createComposeRule() @get:Rule val composeTestRule =
  15. #dcbln22 – @stewemetal A simple UI test @get:Rule val composeTestRule

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } }
  16. #dcbln22 – @stewemetal A simple UI test @get:Rule val composeTestRule

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } }
  17. #dcbln22 – @stewemetal A simple UI test @get:Rule val composeTestRule

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } }
  18. #dcbln22 – @stewemetal A simple UI test @get:Rule val composeTestRule

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } }
  19. #dcbln22 – @stewemetal A simple UI test @get:Rule val composeTestRule

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } !// !!... } } private val entries = listOf( HydrationEntry(…), HydrationEntry(…), HydrationEntry(…), )
  20. #dcbln22 – @stewemetal A simple UI test @get:Rule val composeTestRule

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } } So, how can we act or assert on EntryList? 🤔
  21. #dcbln22 – @stewemetal The semantics tree 🌳

  22. #dcbln22 – @stewemetal The semantics tree 🌳 • A parallel

    tree next to the Composition 🌲🌳 • Used for accessibility and testing • Composables (can) contribute to the semantics tree
  23. #dcbln22 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent

    { Text("100 ml") } } Semantics tree root node Semantic node with text "100 ml"
  24. #dcbln22 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent

    { Text("100 ml") } } Root Text = '[100 ml]'
  25. #dcbln22 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent

    { Row { Text("100 ml") } } } Root Text = '[100 ml]'
  26. #dcbln22 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent

    { Row { Text("100 ml") Text("2022-07-07") } } } Text = '[100 ml]' Text = '[2022-07-07]' Root
  27. #dcbln22 – @stewemetal Visualizing the semantics tree 🌳👀 Text =

    '[100 ml]' Text = '[2022-07-07]' Root Row { Text("100 ml") Text("2022-07-07") }
  28. #dcbln22 – @stewemetal Visualizing the semantics tree 🌳👀 • In

    Compose tests onRoot() .printToLog("RowAndTexts") Text = '[100 ml]' Text = '[2022-07-07]' Root
  29. #dcbln22 – @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-07-07]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-07-07]' Root
  30. #dcbln22 – @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-07-07]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-07-07]' Root
  31. #dcbln22 – @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-07-07]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-07-07]' Root
  32. #dcbln22 – @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-07-07]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-07-07]' Root
  33. #dcbln22 – @stewemetal Visualizing the semantics tree 🌳👀 • In

    Android Studio • Layout Inspector
  34. #dcbln22 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

    = Modifier .semantics { !// !!... } ) { Text("100 ml") Text("2022-07-07") }
  35. #dcbln22 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

    = Modifier .semantics { !// !!... } ) { Text("100 ml") Text("2022-07-07") }
  36. #dcbln22 – @stewemetal Root Text = '[100 ml]' Text =

    '[2022-07-07]' Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { !// !!... } ) { Text("100 ml") Text("2022-07-07") }
  37. #dcbln22 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

    = Modifier .semantics { contentDescription = "List item" } ) { Text("100 ml") Text("2022-07-07") } Root Text = '[100 ml]' Text = '[2022-07-07]' ContentDescription = '[List item]'
  38. #dcbln22 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

    = Modifier .semantics { testTag = "List item" } ) { Text("100 ml") Text("2022-07-07") } Root Text = '[100 ml]' Text = '[2022-07-07]' Tag = '[List item]'
  39. #dcbln22 – @stewemetal Merging semantics - unmerged Row( modifier =

    Modifier .wrapContentHeight( align ) .padding(16.dp) ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } Text = '[2022-07-07]' Text = '[100 ml]' Root = Alignment.CenterVertically,
  40. #dcbln22 – @stewemetal Merging semantics - unmerged Row( modifier =

    Modifier .wrapContentHeight( align ) .padding(16.dp) .semantics(mergeDescendants = false) {} ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } = Text = '[2022-07-07]' Text = '[100 ml]' Root Alignment.CenterVertically,
  41. #dcbln22 – @stewemetal Merging semantics - merged Row( modifier =

    Modifier .wrapContentHeight( align ) .padding(16.dp) .semantics(mergeDescendants = true) {} ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } = Text = '[100 ml, 2022-07-07]' Root Alignment.CenterVertically,
  42. #dcbln22 – @stewemetal Merged semantics by default Role = 'Button'

    Focused = 'false' Text = '[100 ml]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' Button( onClick = {}, ) { Text("100 ml") }
  43. #dcbln22 – @stewemetal Clear semantics Text = '[Careful with this

    😉]' ClearAndSetSemantics = 'true' Button( onClick = {}, modifier = Modifier .clearAndSetSemantics { text = AnnotatedString("Careful with this 😉") }, ) { Text("100 ml") }
  44. #dcbln22 – @stewemetal Clear semantics Text = '[Careful with this

    😉]' ClearAndSetSemantics = 'true' Button( onClick = {}, modifier = Modifier .clearAndSetSemantics { text = AnnotatedString("Careful with this 😉") }, ) { Text("100 ml") }
  45. #dcbln22 – @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 🤩
  46. #dcbln22 – @stewemetal Compose testing API

  47. #dcbln22 – @stewemetal Select nodes - Finders • onRoot onRoot()

    .printToLog(”TAG") Root Text = '[100 ml, 2022-07-07]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-07-07]'
  48. #dcbln22 – @stewemetal Select nodes - Finders • onNodeWithTag onNodeWithTag("List

    item") Root Text = '[100 ml, 2022-07-07]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-07-07]'
  49. #dcbln22 – @stewemetal Select nodes - Finders • onNodeWithContentDescription onNodeWithContentDescription(

    "100 ml on 2022-07-07", ) Root Text = '[100 ml, 2022-07-07]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-07-07]'
  50. #dcbln22 – @stewemetal Select nodes - Finders • onNodeWithText onNodeWithText(

    "100 ml", ) Root Text = '[100 ml, 2022-07-07]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-07-07]'
  51. #dcbln22 – @stewemetal Select nodes - Finders • onNodeWithText onNodeWithText(

    "100 ml", useUnmergedTree = false, ) Root Text = '[100 ml, 2022-07-07]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-07-07]'
  52. #dcbln22 – @stewemetal Select nodes - Finders • onNodeWithText Root

    Text = '[100 ml]' Text = '[2022-07-07]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-07-07]' onNodeWithText( "100 ml", useUnmergedTree = true, )
  53. #dcbln22 – @stewemetal Select nodes - Finders • onAllNodesWith... Tag

    = '[entries_list]' onAllNodesNodeWithTag("List item") Tag = '[List item]' Text = '[100 ml, 2022-07-07]' Tag = '[List item]' Text = '[500 ml, 2022-07-04]' ...
  54. #dcbln22 – @stewemetal Matchers •onNode(<matcher>) • hasText • hasContentDescription •

    hasTestTag • isEnabled • isSelected • isRoot • ...
  55. #dcbln22 – @stewemetal Actions • performClick • performScrollTo... • performTextInput

    • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performClick()
  56. #dcbln22 – @stewemetal Actions • performClick • performScrollTo... • performTextInput

    • performGesture • performKeyPress • ... Role = 'Button' Focused = 'false' Text = '[100 ml]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' onNodeWithText("100 ml") .performClick()
  57. #dcbln22 – @stewemetal Actions • performClick • performScrollTo... • performTextInput

    • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performTextInput( "..." )
  58. #dcbln22 – @stewemetal Actions • performClick • performScrollTo... • performTextInput

    • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performTextInput( "!!..." )
  59. #dcbln22 – @stewemetal Compose testing cheatseet developer.android.com/jetpack/compose/testing-cheatsheet

  60. #dcbln22 – @stewemetal Testing in isolation

  61. #dcbln22 – @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) } } }
  62. #dcbln22 – @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) } }
  63. #dcbln22 – @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) } }
  64. #dcbln22 – @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) } }
  65. #dcbln22 – @stewemetal Testing in isolation composeTestRule.apply { setContent {

    HydrationTrackerTheme { EntryList( entries = entries ) } } onNodeWithTag("entries_list") .onChildren() .assertCountEquals(entries.size) }
  66. #dcbln22 – @stewemetal Testing behavior

  67. #dcbln22 – @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") } } }
  68. #dcbln22 – @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") } } }
  69. #dcbln22 – @stewemetal Testing behavior composeTestRule.apply { var addedDrinkValue =

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

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

    0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }
  72. #dcbln22 – @stewemetal Hybrid UI testing

  73. #dcbln22 – @stewemetal Hybrid UI testing • Adding Compose content

    to existing View-based layouts • Adding existing Views to the composition • Compose & Espresso in the same tests 🪟 🪟 ☕ &
  74. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView

  75. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView <androidx.constraintlayout.widget.ConstraintLayout >

    <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar” style="@style/Widget.MaterialComponents.Toolbar.Primary" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent” /> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height= "wrap_content" app:paddingTop=”8dp" app:layout_constraintTop_toBottomOf="@id/toolbar” /> </androidx.constraintlayout.widget.ConstraintLayout>
  76. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView <androidx.constraintlayout.widget.ConstraintLayout >

    <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar” style="@style/Widget.MaterialComponents.Toolbar.Primary" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent” !/> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height= "wrap_content" app:paddingTop=”8dp" app:layout_constraintTop_toBottomOf="@id/toolbar” !/> !</androidx.constraintlayout.widget.ConstraintLayout>
  77. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView <androidx.constraintlayout.widget.ConstraintLayout >

    <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar” style="@style/Widget.MaterialComponents.Toolbar.Primary" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent” !/> <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" android:layout_width="match_parent" android:layout_height= "wrap_content" app:paddingTop=”8dp" app:layout_constraintTop_toBottomOf="@id/toolbar” !/> !</androidx.constraintlayout.widget.ConstraintLayout>
  78. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView class ComposeViewDemoActivity

    : AppCompatActivity() { private lateinit var binding: ActivityComposeViewDemoBinding // ... } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityComposeViewDemoBinding.inflate(layoutInflater) setContentView(binding.root) }
  79. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView binding.toolbar.title =

    "ComposeView Demo Title" binding.composeView.apply { setViewCompositionStrategy(DisposeOnDetachedFromWindow) setContent { CustomButton( text = "Demo Button", modifier = Modifier .padding(horizontal = 8.dp) .testTag("demo_custom_button"), ) {} } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityComposeViewDemoBinding.inflate(layoutInflater) setContentView(binding.root) }
  80. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4::class) class

    DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule<ComposeViewDemoActivity>() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } }
  81. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4!::class) class

    DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule<ComposeViewDemoActivity>() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } }
  82. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4!::class) class

    DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule<ComposeViewDemoActivity>() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } } ✅
  83. #dcbln22 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4::class) class

    DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule<ComposeViewDemoActivity>() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } } ✅ ✅
  84. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView

  85. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView class CustomButtonView

    @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : ConstraintLayout(context, attrs, defStyleAttr) { !// !!... LayoutInflater.from(context).inflate(R.layout.view_custom_button, this, true) !// !!... fun setText(text: String) { button.text = text } override fun setOnClickListener(listener: OnClickListener?) { button.setOnClickListener { listener!?.onClick(button) } } }
  86. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView @Composable fun

    AndroidViewDemo( onButtonClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("AndroidView Demo Title") }) }, content = { padding -> AndroidView( modifier = Modifier.padding(padding).wrapContentHeight(), factory = { context -> CustomButtonView(context).apply { setText("Demo Button") setOnClickListener { onButtonClick() } } }, ) } ) }
  87. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView @Composable fun

    AndroidViewDemo( onButtonClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("AndroidView Demo Title") }) }, content = { padding -> AndroidView( modifier = Modifier.padding(padding).wrapContentHeight(), factory = { context -> CustomButtonView(context).apply { setText("Demo Button") setOnClickListener { onButtonClick() } } }, ) } ) }
  88. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView @Composable fun

    AndroidViewDemo( onButtonClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("AndroidView Demo Title") }) }, content = { padding -> AndroidView( modifier = Modifier.padding(padding).wrapContentHeight(), factory = { context -> CustomButtonView(context).apply { setText("Demo Button") setOnClickListener { onButtonClick() } } }, ) } ) }
  89. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView @get:Rule val

    composeTestRule = createComposeRule() @Test fun composable_withAndroidView() { composeTestRule.apply { // ... } } var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } }
  90. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView onNodeWithText("AndroidView Demo

    Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) Espresso.onView(withText("Demo Button")).perform(click()) assertEquals(true, buttonClicked) var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } }
  91. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked

    = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) Espresso.onView(withText("Demo Button")).perform(click()) assertEquals(true, buttonClicked) ✅
  92. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked

    = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) Espresso.onView(withText("Demo Button")).perform(click()) assertEquals(true, buttonClicked) ✅ ✅
  93. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked

    = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) assertEquals(true, buttonClicked) ✅ ✅ ✅❓ ❌ Espresso.onView(withText("Demo Button")).perform(click())
  94. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView Doesn’t work

    as of Jetpack Compose 1.2.0-rc03 🙁 https:!//issuetracker.google.com/issues/215231631 Espresso.onView(withText("Demo Button")).perform(click())
  95. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView Espresso.onView(withText("Demo Button")).check

    { view, _ -> view.performClick() } Unstable workaround https:!//issuetracker.google.com/issues/215231631
  96. #dcbln22 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked

    = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) assertEquals(true, buttonClicked) Espresso.onView(withText("Demo Button")).check { view, _ -> view.performClick() } ✅ ✅ ✅ ✅❓
  97. #dcbln22 – @stewemetal Topics to check out • Testing animations

    in Compose 👀 • Synchronization and IdlingResources 🕞 • Screenshot testing with Paparazzi, Showkase, etc. 📸
  98. #dcbln22 – @stewemetal Resources • developer.android.com/jetpack/compose/testing • developer.android.com/jetpack/compose/testing-cheatsheet • developer.android.com/jetpack/compose/accessibility

    • developer.android.com/jetpack/compose/semantics • youtu.be/kdwofTaEHrs • adbackstage.libsyn.com/episode-171-compose-testing • github.com/android/compose-samples • adavis.info/2021/09/testing-hybrid-jetpack-compose-apps.html • github.com/stewemetal/composehydrationtracker
  99. stewemetal How to Test Your Compose UI István Juhos •

    Testing based on the semantics tree • Planned accessibility = good testability • Testing composables in isolation helps a lot • Hybrid UI testing is supported out of the box Cover photo by Rob Mulally on Unsplash