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

UI testing of Jetpack Compose apps - Dutch Android User Group

UI testing of Jetpack Compose apps - Dutch Android User Group

As Android developers, we face many challenges like handling life-cycle events, maintaining view state, testing applications that use different UI approaches, etc. Many Android developers already prefer to use Jetpack Compose together or instead of the traditional way of building UI for Android apps.

During this talk, we will explore different techniques of testing the UI of Android applications, how efficiently verify apps which includes both approaches to building UI.

Alex Zhukovich

April 07, 2022
Tweet

More Decks by Alex Zhukovich

Other Decks in Technology

Transcript

  1. PIXEL PERFECTNESS INTERACTION TESTING UI COMPONENTS 
 (DESIGN SYSTEM) INTERACTION

    WITH SCREEN(S) SUPPORT MULTIPLE LANGUAGES SUPPORT DIFFERENT 
 COLOR THEMES SUPPORT ACCESSIBILITY OPTIONS 
 (FONT SIZE, SCREEN SIZE, ETC) SUPPORT LTR & RTL PERMISSION TESTING YES YES YES YES YES YES YES YES
  2. launchActivity launchFragmentInContainer ComposeContentTestRule val activityOptions = bundleOf( Pair("param-1", 1), Pair("param-2",

    2), Pair("param-3", 3), ) val scenario = ActivityScenario.launch( MainActivity::class.java, activityOptions ) scenario.moveToState(Lifecycle.State.STARTED) ActivityScenario.launch(
  3. launchActivity launchFragmentInContainer ComposeContentTestRule val scenario = launchFragmentInContainer( fragmentArgs: Bundle? =

    null, @StyleRes themeResId: Int = 
 R.style.FragmentScenarioEmptyFragmentActivityTheme, initialState: Lifecycle.State = 
 Lifecycle.State.RESUMED, factory: FragmentFactory? = null ) scenario.moveToState(Lifecycle.State.STARTED) debugImplementation "androidx.fragment:fragment-testing:1.4.0"
  4. launchActivity launchFragmentInContainer ComposeContentTestRule @get:Rule val composeTestRule = createComposeRule() composeTestRule.apply {

    setContent { LoginScreen() } onNodeWithTag("email") .performTextInput("[email protected]") onNodeWithTag("password") .performTextInput("password") onNodeWithTag("login") .performClick() ... } debugImplementation "androidx.compose.ui:ui-test-manifest:1:0:5"
  5. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("[email protected]") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } }
  6. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("[email protected]") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher
  7. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("[email protected]") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher Action
  8. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("[email protected]") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher Action Assertion
  9. MERGED NODE TREE UNMERGED NODE TREE Printing with useUnmergedTree =

    ‘false’ Node #1 at (l=0.0, t=74.0, r=1080.0, b=2012.0)px |-Node #2 at (l=247.0, t=956.0, r=834.0, b=1130.0)px Role = 'Button' Focused = 'false' ContentDescription = '[Schedule a task]' Text = '[Schedule a task]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' Printing with useUnmergedTree = ‘true’ Node #1 at (l=0.0, t=74.0, r=1080.0, b=2012.0)px |-Node #2 at (l=247.0, t=956.0, r=834.0, b=1130.0)px Role = 'Button' Focused = 'false' Actions = [OnClick] MergeDescendants = 'true' |-Node #4 at (l=321.0, t=1006.0, r=395.0, b=1080.0)px | ContentDescription = '[Schedule a task]' | Role = 'Image' |-Node #5 at (l=420.0, t=1014.0, r=785.0, b=1072.0)px Text = '[Schedule a task]' Actions = [GetTextLayoutResult]
  10. MERGED NODE TREE UNMERGED NODE TREE Printing with useUnmergedTree =

    ‘true’ Node #1 at (l=0.0, t=74.0, r=1080.0, b=2012.0)px |-Node #2 at (l=247.0, t=956.0, r=834.0, b=1130.0)px Role = 'Button' Focused = 'false' Actions = [OnClick] MergeDescendants = 'true' |-Node #4 at (l=321.0, t=1006.0, r=395.0, b=1080.0)px | ContentDescription = '[Schedule a task]' | Role = 'Image' |-Node #5 at (l=420.0, t=1014.0, r=785.0, b=1072.0)px Text = '[Schedule a task]' Actions = [GetTextLayoutResult] Printing with useUnmergedTree = ‘false’ Node #1 at (l=0.0, t=74.0, r=1080.0, b=2012.0)px |-Node #2 at (l=247.0, t=956.0, r=834.0, b=1130.0)px Role = 'Button' Focused = 'false' ContentDescription = '[Schedule a task]' Text = '[Schedule a task]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' onRoot(useUnmergedTree = false) 
 .printToLog("MERGED") onRoot(useUnmergedTree = true) .printToLog("UNMERGED")
  11. MERGED NODE TREE UNMERGED NODE TREE onNode(hasText("Schedule a task"), useUnmergedTree

    = true) .assertHasClickAction() Error onNode(hasText("Schedule a task")) .assertHasClickAction() useUnmergedTree = false Printing with useUnmergedTree = ‘true’ Node #1 at (l=0.0, t=74.0, r=1080.0, b=2012.0)px |-Node #2 at (l=247.0, t=956.0, r=834.0, b=1130.0)px Role = 'Button' Focused = 'false' Actions = [OnClick] MergeDescendants = 'true' |-Node #4 at (l=321.0, t=1006.0, r=395.0, b=1080.0)px | ContentDescription = '[Schedule a task]' | Role = 'Image' |-Node #5 at (l=420.0, t=1014.0, r=785.0, b=1072.0)px Text = '[Schedule a task]' Actions = [GetTextLayoutResult] Printing with useUnmergedTree = ‘false’ Node #1 at (l=0.0, t=74.0, r=1080.0, b=2012.0)px |-Node #2 at (l=247.0, t=956.0, r=834.0, b=1130.0)px Role = 'Button' Focused = 'false' ContentDescription = '[Schedule a task]' Text = '[Schedule a task]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' onRoot(useUnmergedTree = false) 
 .printToLog("MERGED") onRoot(useUnmergedTree = true) .printToLog("UNMERGED")
  12. Node #1 at (l=0.0, t=74.0, r=1080.0, b=1188.0)px |-Node #2 at

    (l=0.0, t=74.0, r=1080.0, b=1016.0)px | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)' | CollectionInfo = 'androidx.compose.ui.semantics.CollectionInfo@75c2267' | Actions = [IndexForKey, ScrollBy, ScrollToIndex] | |-Node #4 at (l=271.0, t=99.0, r=907.0, b=173.0)px | | Text = '[Americano]' | | Actions = [GetTextLayoutResult] | |-Node #5 at (l=271.0, t=185.0, r=907.0, b=345.0)px | | Text = '[Americano is ...]' | | Actions = [GetTextLayoutResult] | |-Node #6 at (l=932.0, t=99.0, r=1055.0, b=360.0)px | | |-Node #7 at (l=932.0, t=99.0, r=1055.0, b=186.0)px | | | Focused = 'false' | | | Text = '[+]' | | | Actions = [OnClick, GetTextLayoutResult] | | | MergeDescendants = 'true' | | |-Node #10 at (l=976.0, t=186.0, r=1011.0, b=273.0)px | | | Text = '[0]' | | | Actions = [GetTextLayoutResult] | | |-Node #11 at (l=932.0, t=273.0, r=1055.0, b=360.0)px | | Focused = 'false' | | Text = '[—]' | | Actions = [OnClick, GetTextLayoutResult] | | MergeDescendants = 'true' | |-Node #14 at (l=271.0, t=413.0, r=907.0, b=487.0)px | | Text = '[Cappuccino]' | | Actions = [GetTextLayoutResult] | |-Node #15 at (l=271.0, t=499.0, r=907.0, b=659.0)px | | Text = '[A cappuccino is ...]’ | | Actions = [GetTextLayoutResult] | |-Node #16 at (l=932.0, t=413.0, r=1055.0, b=674.0)px | | |-Node #17 ... | | |-Node #20 ... | | |-Node #21 ... | |-Node #24 at (l=271.0, t=727.0, r=907.0, b=801.0)px | | Text = '[Espresso]' | | Actions = [GetTextLayoutResult] | |-Node #25 at (l=271.0, t=813.0, r=907.0, b=973.0)px | | Text = '[Espresso is ...]’ | | Actions = [GetTextLayoutResult] | |-Node #26 at (l=932.0, t=727.0, r=1055.0, b=988.0)px | |-Node #27 ... | |-Node #30 ... | |-Node #31 ... |-Node #3 at (l=49.0, t=1065.0, r=1031.0, b=1139.0)px Text = '[Pay (€ 0)]' Actions = [GetTextLayoutResult]
  13. Node #1 at (l=0.0, t=74.0, r=1080.0, b=1188.0)px |-Node #2 at

    (l=0.0, t=74.0, r=1080.0, b=1016.0)px | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)' | CollectionInfo = 'androidx.compose.ui.semantics.CollectionInfo@75c2267' | Actions = [IndexForKey, ScrollBy, ScrollToIndex] | |-Node #4 at (l=25.0, t=99.0, r=1055.0, b=360.0)px | | |-Node #5 at (l=271.0, t=99.0, r=907.0, b=173.0)px | | | Text = '[Americano]' | | | Actions = [GetTextLayoutResult] | | |-Node #6 at (l=271.0, t=185.0, r=907.0, b=345.0)px | | | Text = '[Americano is ...]’ | | | Actions = [GetTextLayoutResult] | | |-Node #7 at (l=932.0, t=99.0, r=1055.0, b=360.0)px | | |-Node #8 at (l=932.0, t=99.0, r=1055.0, b=186.0)px | | | Focused = 'false' | | | Text = '[+]' | | | Actions = [OnClick, GetTextLayoutResult] | | | MergeDescendants = 'true' | | |-Node #11 at (l=976.0, t=186.0, r=1011.0, b=273.0)px | | | Text = '[0]' | | | Actions = [GetTextLayoutResult] | | |-Node #12 at (l=932.0, t=273.0, r=1055.0, b=360.0)px | | Focused = 'false' | | Text = '[—]' | | Actions = [OnClick, GetTextLayoutResult] | | MergeDescendants = 'true' | |-Node #15 at (l=25.0, t=413.0, r=1055.0, b=674.0)px | | |-Node #16 at (l=271.0, t=413.0, r=907.0, b=487.0)px | | | Text = '[Cappuccino]' | | | Actions = [GetTextLayoutResult] | | |-Node #17 at (l=271.0, t=499.0, r=907.0, b=659.0)px | | | Text = '[A cappuccino is ...]’ | | | Actions = [GetTextLayoutResult] | | |-Node #18 at (l=932.0, t=413.0, r=1055.0, b=674.0)px | | |-Node #19 ... | | |-Node #22 ... | | |-Node #23 ... | |-Node #26 at (l=25.0, t=727.0, r=1055.0, b=988.0)px | |-Node #27 at (l=271.0, t=727.0, r=907.0, b=801.0)px | | Text = '[Espresso]' | | Actions = [GetTextLayoutResult] | |-Node #28 at (l=271.0, t=813.0, r=907.0, b=973.0)px | | Text = '[Espresso is ...]’ | | Actions = [GetTextLayoutResult] | |-Node #29 at (l=932.0, t=727.0, r=1055.0, b=988.0)px | |-Node #30 ... | |-Node #33 ... | |-Node #34 ... |-Node #3 at (l=49.0, t=1065.0, r=1031.0, b=1139.0)px Text = '[Pay (€ 0)]' Actions = [GetTextLayoutResult] Modifier.semantics(mergeDescendants = false) {}
  14. Modifier.semantics(mergeDescendants = false) {} class CoffeeDrinksTest { @get:Rule val composeTestRule

    = createComposeRule() @Test fun coffeeDrinksAreNotAddedToBasket_whenCoffeeAddedToBasket_thenPaymentAmountRecalculated() { composeTestRule.apply { setContent { CoffeeDrinksWithBasket(drinks = coffeeDrinks) } onNode(withIncrementForCoffeeDrink("Espresso")) .performClick() onNode(withIncrementForCoffeeDrink("Americano")) .performClick() onNodeWithIncrementForCoffeeDrink("Americano") .performClick() onNode(hasText("Pay (€ 18.5)”)) .assertIsDisplayed() } } } fun withIncrementForCoffeeDrink(text: String): SemanticsMatcher { return hasText("+") .and(hasAnyAncestor(hasAnyChild(hasText(text)))) } fun SemanticsNodeInteractionsProvider.onNodeWithIncrementForCoffeeDrink(text: String) = onNode(withIncrementForCoffeeDrink(text))
  15. Modifier.semantics(mergeDescendants = false) {} class CoffeeDrinksTest { @get:Rule val composeTestRule

    = createComposeRule() @Test fun coffeeDrinksAreNotAddedToBasket_whenCoffeeAddedToBasket_thenPaymentAmountRecalculated() { composeTestRule.apply { setContent { CoffeeDrinksWithBasket(drinks = coffeeDrinks) } onNode(withIncrementForCoffeeDrink("Espresso")) .performClick() onNode(withIncrementForCoffeeDrink("Americano")) .performClick() onNodeWithIncrementForCoffeeDrink("Americano") .performClick() onNode(hasText("Pay (€ 18.5)”)) .assertIsDisplayed() } } } fun withIncrementForCoffeeDrink(text: String): SemanticsMatcher { return hasText("+") .and(hasAnyAncestor(hasAnyChild(hasText(text)))) } fun SemanticsNodeInteractionsProvider.onNodeWithIncrementForCoffeeDrink(text: String) = onNode(withIncrementForCoffeeDrink(text))
  16. @Preview @Composable fun Preview_MultipleRadioButtons() { val selectedValue = remember {

    mutableStateOf("") } val isSelectedItem: (String) -> Boolean = { selectedValue.value == it } val onChangeState: (String) -> Unit = { selectedValue.value = it } val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5") Column(Modifier.padding(8.dp)) { Text(text = "Selected value: ${selectedValue.value.ifEmpty { "NONE" }}") items.forEach { item -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.selectable( selected = isSelectedItem(item), onClick = { onChangeState(item) }, role = Role.RadioButton ).padding(8.dp) ) { RadioButton( selected = isSelectedItem(item), onClick = null ) Text( text = item, modifier = Modifier.fillMaxWidth() ) } } } }
  17. @Preview @Composable fun Preview_MultipleRadioButtons() { val selectedValue = remember {

    mutableStateOf("") } val isSelectedItem: (String) -> Boolean = { selectedValue.value == it } val onChangeState: (String) -> Unit = { selectedValue.value = it } val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5") Column(Modifier.padding(8.dp)) { Text(text = "Selected value: ${selectedValue.value.ifEmpty { "NONE" }}") items.forEach { item -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.selectable( selected = isSelectedItem(item), onClick = { onChangeState(item) }, role = Role.RadioButton ).padding(8.dp) ) { RadioButton( selected = isSelectedItem(item), onClick = null ) Text( text = item, modifier = Modifier.fillMaxWidth() ) } } } }
  18. @ExperimentalFoundationApi class RadioButtonTest { @get:Rule val composeTestRule = createComposeRule() @Test

    fun firstItemSelectedByDefault_whenThirdItemIsSelected_thenSelectedValueDisplayed() { composeTestRule.apply { setContent { Demo_RadioButton () } onNodeWithRoleAndText(Role.RadioButton, "Item 1") .assertIsSelected() onNodeWithText("Item 3") .performClick() onNodeWithRoleAndText(Role.RadioButton, "Item 3") .assertIsSelected() onNodeWithText("Selected value: Item 3") .assertIsDisplayed() } } private fun withRole(role: Role): SemanticsMatcher { return SemanticsMatcher("${SemanticsProperties.Role.name} contains '$role'") { it.config.getOrNull(SemanticsProperties.Role) == role } } private fun SemanticsNodeInteractionsProvider.onNodeWithRoleAndText( role: Role, text: String ) = onNode( withRole(role) .and(isSelectable()) .and(isEnabled()) .and(hasText(text)) ) }
  19. @ExperimentalFoundationApi class RadioButtonTest { @get:Rule val composeTestRule = createComposeRule() @Test

    fun firstItemSelectedByDefault_whenThirdItemIsSelected_thenSelectedValueDisplayed() { composeTestRule.apply { setContent { Demo_RadioButton () } onNodeWithRoleAndText(Role.RadioButton, "Item 1") .assertIsSelected() onNodeWithText("Item 3") .performClick() onNodeWithRoleAndText(Role.RadioButton, "Item 3") .assertIsSelected() onNodeWithText("Selected value: Item 3") .assertIsDisplayed() } } private fun withRole(role: Role): SemanticsMatcher { return SemanticsMatcher("${SemanticsProperties.Role.name} contains '$role'") { it.config.getOrNull(SemanticsProperties.Role) == role } } private fun SemanticsNodeInteractionsProvider.onNodeWithRoleAndText( role: Role, text: String ) = onNode( withRole(role) .and(isSelectable()) .and(isEnabled()) .and(hasText(text)) ) }
  20. COLUMN LAZY COLUMN val amount = 1_000 LazyColumn( modifier =

    Modifier.testTag("list") ) { items(amount) { Row( modifier = Modifier.padding(8.dp) ) { Text(text = "Item $it", fontSize = 20.sp) } } } val amount = 1_000 Column( modifier = Modifier.testTag("list") ) { (1..amount).forEach { Row( modifier = Modifier.padding(8.dp) ) { Text(text = "Item $it", fontSize = 20.sp) } } }
  21. COLUMN ADD UI val amount = 1_000 Column( modifier =

    Modifier.testTag("list") ) { (0..amount).forEach { Row( modifier = Modifier.padding(8.dp) ) { Text(text = "Item $it", fontSize = 20.sp) } } } onNode(hasText("Item 14")) .assertIsDisplayed() onNode(hasTestTag("list")) .performScrollToIndex(80) onNode(hasText("Item 80")) .assertIsDisplayed() Scroll actions: performScrollToNode(hasText("Item 80”)) performScrollToIndex(80) 15 ITEMS ARE DISPLAYED ALL ITEMS ARE EXIST
  22. LAZY COLUMN ADD UI val amount = 1_000 LazyColumn( modifier

    = Modifier.testTag("list") ) { items(amount) { Row( modifier = Modifier.padding(8.dp) ) { Text(text = "Item $it", fontSize = 20.sp) } } } onNode(hasText("Item 14")) .assertIsDisplayed() onNode(hasTestTag("list")) .performScrollToIndex(80) onNode(hasText("Item 80")) .assertIsDisplayed() Scroll actions: performScrollToNode(hasText("Item 80”)) performScrollToIndex(80) 15 ITEMS ARE DISPLAYED 15 ITEMS ARE EXIST
  23. class TextTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    chapter1IsSelectedByDefault_whenSwipeLeftTheSecondAndThirdChapterIsDisplayed() { composeTestRule.apply { setContent { val content = listOf( listOf("Lorem ipsum ...", ...), listOf(...), listOf(...) ) Demo_capitalizeTheFirstLetterOfBookChapter(content) } onNodeWithText("Chapter 1") .assertIsDisplayed() onNode(hasSelectableGroup()) .performTouchInput { swipeLeft() } onNodeWithText("Chapter 2") .assertIsDisplayed() onNode(hasSelectableGroup()) .performTouchInput { swipeLeft() } onNodeWithText("Chapter 3") .assertIsDisplayed() } } fun hasSelectableGroup() = SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup) }
  24. class TextTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    chapter1IsSelectedByDefault_whenSwipeLeftTheSecondAndThirdChapterIsDisplayed() { composeTestRule.apply { setContent { val content = listOf( listOf("Lorem ipsum ...", ...), listOf(...), listOf(...) ) Demo_capitalizeTheFirstLetterOfBookChapter(content) } onNodeWithText("Chapter 1") .assertIsDisplayed() onNode(hasSelectableGroup()) .performTouchInput { swipeLeft() } onNodeWithText("Chapter 2") .assertIsDisplayed() onNode(hasSelectableGroup()) .performTouchInput { swipeLeft() } onNodeWithText("Chapter 3") .assertIsDisplayed() } } fun hasSelectableGroup() = SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup) }
  25. class AnimationTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    coffeeDrinkAnimation() { composeTestRule.apply { setContent { CoffeeDrinkAnimationBox() } composeTestRule.mainClock.autoAdvance = false compareScreenshot(composeTestRule, "test-state-0") composeTestRule.mainClock.advanceTimeBy(150) compareScreenshot(composeTestRule, "test-state-1") composeTestRule.mainClock.advanceTimeBy(200) compareScreenshot(composeTestRule, "test-state-2") composeTestRule.mainClock.advanceTimeBy(400) compareScreenshot(composeTestRule, "test-state-3") composeTestRule.mainClock.advanceTimeBy(600) compareScreenshot(composeTestRule, "test-state-4") composeTestRule.mainClock.advanceTimeBy(800) compareScreenshot(composeTestRule, "test-state-5") } } }