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

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

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. stewemetal
    How to Test Your Compose UI
    István Juhos

    View Slide

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

    View Slide

  3. #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

    View Slide

  4. #DroidKaigi2022 – @stewemetal
    Testing Compose UI
    🧪

    View Slide

  5. #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




    🧪

    View Slide

  6. #DroidKaigi2022 – @stewemetal
    @Composable
    fun EntryList(
    entries: List = 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

    View Slide

  7. #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

    View Slide

  8. #DroidKaigi2022 – @stewemetal
    Setting up Compose UI tests

    View Slide

  9. #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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  15. #DroidKaigi2022 – @stewemetal
    Setting up Compose UI tests
    @RunWith(AndroidJUnit4::class)
    class EntryListTest {
    // ...
    }
    createComposeRule()
    @get:Rule
    val composeTestRule =

    View Slide

  16. #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 =

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  21. #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(…),
    )

    View Slide

  22. #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? 🤔

    View Slide

  23. #DroidKaigi2022 – @stewemetal
    The semantics tree 🌳

    View Slide

  24. #DroidKaigi2022 – @stewemetal
    The semantics tree 🌳
    • A parallel tree next to the Composition 🌲🌳
    • Used for accessibility and testing
    • Composables (can) contribute to the semantics tree

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  28. #DroidKaigi2022 – @stewemetal
    The semantics tree 🌳
    composeTestRule.apply {
    setContent {
    Row {
    Text("100 ml")
    Text("2022-10-05")
    }
    }
    }
    Text = '[100 ml]' Text = '[2022-10-05]'
    Root

    View Slide

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

    View Slide

  30. #DroidKaigi2022 – @stewemetal
    Visualizing the semantics tree 🌳👀
    • In Compose tests
    onRoot()
    .printToLog("RowAndTexts") Text = '[100 ml]' Text = '[2022-10-05]'
    Root

    View Slide

  31. #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

    View Slide

  32. #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

    View Slide

  33. #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

    View Slide

  34. #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

    View Slide

  35. #DroidKaigi2022 – @stewemetal
    Visualizing the semantics tree 🌳👀
    • In Android Studio
    • Layout Inspector

    View Slide

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

    View Slide

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

    View Slide

  38. #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")
    }

    View Slide

  39. #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]'

    View Slide

  40. #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]'

    View Slide

  41. #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,

    View Slide

  42. #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,

    View Slide

  43. #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,

    View Slide

  44. #DroidKaigi2022 – @stewemetal
    Merged semantics by default
    Role = 'Button'
    Focused = 'false'
    Text = '[100 ml]'
    Actions = [OnClick, GetTextLayoutResult]
    MergeDescendants = 'true'
    Button(
    onClick = {},
    ) {
    Text("100 ml")
    }

    View Slide

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

    View Slide

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

    View Slide

  47. #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 🤩

    View Slide

  48. #DroidKaigi2022 – @stewemetal
    Compose testing API

    View Slide

  49. #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]'

    View Slide

  50. #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]'

    View Slide

  51. #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]'

    View Slide

  52. #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]'

    View Slide

  53. #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]'

    View Slide

  54. #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,
    )

    View Slide

  55. #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]'
    !!...

    View Slide

  56. #DroidKaigi2022 – @stewemetal
    Matchers
    •onNode()
    • hasText
    • hasContentDescription
    • hasTestTag
    • isEnabled
    • isSelected
    • isRoot
    • ...

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  61. #DroidKaigi2022 – @stewemetal
    Compose testing cheatseet
    developer.android.com/jetpack/compose/testing-cheatsheet

    View Slide

  62. #DroidKaigi2022 – @stewemetal
    Testing in isolation

    View Slide

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

    View Slide

  64. #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)
    }
    }

    View Slide

  65. #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)
    }
    }

    View Slide

  66. #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)
    }
    }

    View Slide

  67. #DroidKaigi2022 – @stewemetal
    Testing in isolation
    composeTestRule.apply {
    setContent {
    HydrationTrackerTheme {
    EntryList(
    entries = entries
    )
    }
    }
    onNodeWithTag("entries_list")
    .onChildren()
    .assertCountEquals(entries.size)
    }

    View Slide

  68. #DroidKaigi2022 – @stewemetal
    Testing behavior

    View Slide

  69. #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") }
    }
    }

    View Slide

  70. #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") }
    }
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  74. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing

    View Slide

  75. #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
    🪟
    🪟

    &

    View Slide

  76. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing – ComposeView

    View Slide

  77. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing – ComposeView

    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” />
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height= "wrap_content"
    app:paddingTop=”8dp"
    app:layout_constraintTop_toBottomOf="@id/toolbar” />

    View Slide

  78. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing – ComposeView

    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” />
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height= "wrap_content"
    app:paddingTop=”8dp"
    app:layout_constraintTop_toBottomOf="@id/toolbar” />

    View Slide

  79. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing – ComposeView

    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” />
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height= "wrap_content"
    app:paddingTop=”8dp"
    app:layout_constraintTop_toBottomOf="@id/toolbar” />

    View Slide

  80. #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)
    }

    View Slide

  81. #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)
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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


    View Slide

  86. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing - AndroidView

    View Slide

  87. #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) }
    }
    }

    View Slide

  88. #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() }
    }
    },
    )
    }
    )
    }

    View Slide

  89. #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() }
    }
    },
    )
    }
    )
    }

    View Slide

  90. #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() }
    }
    },
    )
    }
    )
    }

    View Slide

  91. #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 }
    }
    }

    View Slide

  92. #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 }
    }
    }

    View Slide

  93. #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)

    View Slide

  94. #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)


    View Slide

  95. #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())

    View Slide

  96. #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())

    View Slide

  97. #DroidKaigi2022 – @stewemetal
    Hybrid UI testing - AndroidView
    Espresso.onView(withText("Demo Button")).check { view, _ ->
    view.performClick()
    }
    Unstable workaround
    https://issuetracker.google.com/issues/215231631

    View Slide

  98. #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()
    }



    ✅❓

    View Slide

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

    View Slide

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

    View Slide

  101. #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

    View Slide

  102. 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

    View Slide

  103. #DroidKaigi2022 – @stewemetal
    Thank you! 🙏
    ありがとうございました

    View Slide