Slide 1

Slide 1 text

stewemetal How to Test Your Compose UI István Juhos

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

Kotlin Budapest Meetup – @stewemetal Testing Views - Recap ☕ !

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

Kotlin Budapest Meetup – @stewemetal Testing Compose UI 🧪

Slide 9

Slide 9 text

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"

Slide 10

Slide 10 text

Kotlin Budapest Meetup – @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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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 ) } } !// !!... } }

Slide 20

Slide 20 text

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 ) } } !// !!... } }

Slide 21

Slide 21 text

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 ) } } !// !!... } }

Slide 22

Slide 22 text

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 ) } } !// !!... } }

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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? 🤔

Slide 25

Slide 25 text

Kotlin Budapest Meetup – @stewemetal The semantics tree 🌳

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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") }

Slide 32

Slide 32 text

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") }

Slide 33

Slide 33 text

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]'

Slide 34

Slide 34 text

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]'

Slide 35

Slide 35 text

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]'

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

Kotlin Budapest Meetup – @stewemetal Visualizing the semantics tree 🌳👀 • In Android Studio • Layout Inspector

Slide 44

Slide 44 text

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 =

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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}", ) } =

Slide 47

Slide 47 text

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'

Slide 48

Slide 48 text

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 🤩

Slide 49

Slide 49 text

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]'

Slide 50

Slide 50 text

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]'

Slide 51

Slide 51 text

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]'

Slide 52

Slide 52 text

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]'

Slide 53

Slide 53 text

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]'

Slide 54

Slide 54 text

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, )

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

Kotlin Budapest Meetup – @stewemetal Actions • performClick • performScrollTo!!... • performTextInput • performGesture • performKeyPress • !!... onNodeWithText("100 ml") .performClick()

Slide 57

Slide 57 text

Kotlin Budapest Meetup – @stewemetal Assertions • assertExists • assertIsDisplayed • assertIsEnabled • assertContentDescription • assertTextEquals • assert() with matchers • !!... onNodeWithText("100 ml") .assertIsEnabled()

Slide 58

Slide 58 text

Kotlin Budapest Meetup – @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) } } }

Slide 59

Slide 59 text

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) } }

Slide 60

Slide 60 text

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) } }

Slide 61

Slide 61 text

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) } }

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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") } } }

Slide 64

Slide 64 text

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") } } }

Slide 65

Slide 65 text

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) }

Slide 66

Slide 66 text

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) }

Slide 67

Slide 67 text

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) }

Slide 68

Slide 68 text

Kotlin Budapest Meetup – @stewemetal Topics to check out • Testing Compose animations • Testing Views in Composables • Synchronization and IdlingResources

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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