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

How to Test Your Compose UI (DroidKaigi 2022, T...

How to Test Your Compose UI (DroidKaigi 2022, Tokyo)

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 DroidKaigi 2022, Tokyo.

Video is available on the following link:
https://youtu.be/OzYJIJT_9F8

István Juhos

October 05, 2022
Tweet

More Decks by István Juhos

Other Decks in Programming

Transcript

  1. #DroidKaigi2022 – @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
  2. #DroidKaigi2022 – @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 ❌ ❌ ❌ ❌ 🧪
  3. #DroidKaigi2022 – @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 #DroidKaigi2022 – @stewemetal EntryList HydrationItem
  4. #DroidKaigi2022 – @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 HydrationItem EntryList #DroidKaigi2022 – @stewemetal
  5. #DroidKaigi2022 – @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
  6. #DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4!::class) class

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

    EntryListTest { // ... } create ComposeRule () Android <MainActivity> @get:Rule val composeTestRule = https://www.droidcon.com/2022/08/01/modern-testing-on-android/ Modern Testing on Android
  8. #DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class

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

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

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

    EntryListTest { // ... } createComposeRule() @get:Rule val composeTestRule =
  12. #DroidKaigi2022 – @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 =
  13. #DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule

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

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

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

    = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } "// ""... } }
  17. #DroidKaigi2022 – @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(…), )
  18. #DroidKaigi2022 – @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? 🤔
  19. #DroidKaigi2022 – @stewemetal The semantics tree 🌳 • A parallel

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

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

    { Row { Text("100 ml") Text("2022-10-05") } } } Text = '[100 ml]' Text = '[2022-10-05]' Root
  22. #DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 Text =

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

    Compose tests onRoot() .printToLog("RowAndTexts") Text = '[100 ml]' Text = '[2022-10-05]' Root
  24. #DroidKaigi2022 – @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-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root
  25. #DroidKaigi2022 – @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-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root
  26. #DroidKaigi2022 – @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-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root
  27. #DroidKaigi2022 – @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-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root
  28. #DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

    = Modifier .semantics { // ... } ) { Text("100 ml") Text("2022-10-05") }
  29. #DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

    = Modifier .semantics { // ... } ) { Text("100 ml") Text("2022-10-05") }
  30. #DroidKaigi2022 – @stewemetal Root Text = '[100 ml]' Text =

    '[2022-10-05]' Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { // ... } ) { Text("100 ml") Text("2022-10-05") }
  31. #DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier

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

    = Modifier .semantics { testTag = "List item" } ) { Text("100 ml") Text("2022-10-05") } Root Text = '[100 ml]' Text = '[2022-10-05]' Tag = '[List item]'
  33. #DroidKaigi2022 – @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-10-05]' Text = '[100 ml]' Root = Alignment.CenterVertically,
  34. #DroidKaigi2022 – @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-10-05]' Text = '[100 ml]' Root Alignment.CenterVertically,
  35. #DroidKaigi2022 – @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-10-05]' Root Alignment.CenterVertically,
  36. #DroidKaigi2022 – @stewemetal Merged semantics by default Role = 'Button'

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

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

    😉]' ClearAndSetSemantics = 'true' Button( onClick = {}, modifier = Modifier .clearAndSetSemantics { text = AnnotatedString("Careful with this 😉") }, ) { Text("100 ml") }
  39. #DroidKaigi2022 – @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 🤩
  40. #DroidKaigi2022 – @stewemetal Select nodes - Finders • onRoot onRoot()

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

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

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

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

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

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

    = '[entries_list]' onAllNodesNodeWithTag("List item") Tag = '[List item]' Text = '[100 ml, 2022-10-05]' Tag = '[List item]' Text = '[500 ml, 2022-07-04]' !!...
  47. #DroidKaigi2022 – @stewemetal Actions • performClick • performScrollTo... • performTextInput

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

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

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

    • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performTextInput( "..." )
  51. #DroidKaigi2022 – @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) } } }
  52. #DroidKaigi2022 – @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) } }
  53. #DroidKaigi2022 – @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) } }
  54. #DroidKaigi2022 – @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) } }
  55. #DroidKaigi2022 – @stewemetal Testing in isolation composeTestRule.apply { setContent {

    HydrationTrackerTheme { EntryList( entries = entries ) } } onNodeWithTag("entries_list") .onChildren() .assertCountEquals(entries.size) }
  56. #DroidKaigi2022 – @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") } } }
  57. #DroidKaigi2022 – @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") } } }
  58. #DroidKaigi2022 – @stewemetal Testing behavior composeTestRule.apply { var addedDrinkValue =

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

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

    0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }
  61. #DroidKaigi2022 – @stewemetal Hybrid UI testing • Adding Compose content

    to existing View-based layouts • Adding existing Views to the composition • Compose & Espresso in the same tests 🪟 🪟 ☕ &
  62. #DroidKaigi2022 – @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>
  63. #DroidKaigi2022 – @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>
  64. #DroidKaigi2022 – @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>
  65. #DroidKaigi2022 – @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) }
  66. #DroidKaigi2022 – @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) }
  67. #DroidKaigi2022 – @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() } } }
  68. #DroidKaigi2022 – @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() } } }
  69. #DroidKaigi2022 – @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() } } } ✅
  70. #DroidKaigi2022 – @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() } } } ✅ ✅
  71. #DroidKaigi2022 – @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) } } }
  72. #DroidKaigi2022 – @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() } } }, ) } ) }
  73. #DroidKaigi2022 – @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() } } }, ) } ) }
  74. #DroidKaigi2022 – @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() } } }, ) } ) }
  75. #DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView @get:Rule val

    composeTestRule = createComposeRule() @Test fun composable_withAndroidView() { composeTestRule.apply { // ... } } var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } }
  76. #DroidKaigi2022 – @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 } } }
  77. #DroidKaigi2022 – @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) ✅
  78. #DroidKaigi2022 – @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) ✅ ✅
  79. #DroidKaigi2022 – @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())
  80. #DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView Doesn’t work

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

    { view, _ -> view.performClick() } Unstable workaround https://issuetracker.google.com/issues/215231631
  82. #DroidKaigi2022 – @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() } ✅ ✅ ✅ ✅❓
  83. #DroidKaigi2022 – @stewemetal Topics to check out • Testing animations

    in Compose 👀 • Synchronization and IdlingResources 🕞 • Screenshot testing with Paparazzi, Showkase, etc. 📸
  84. #DroidKaigi2022 – @stewemetal Topics to check out • Testing animations

    in Compose 👀 • Synchronization and IdlingResources 🕞 • Screenshot testing with Paparazzi, Showkase, etc. 📸
  85. #DroidKaigi2022 – @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
  86. 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