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

UI Testing of Jetpack Compose apps - Droidcon I...

UI Testing of Jetpack Compose apps - Droidcon Italy 2023

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 Android applications' UI and how to efficiently verify apps, which includes both approaches of building UI.

Alex Zhukovich

October 13, 2023
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. SCREENSHOT TESTS UI COMPONENTS Veri fi cation of UI components

    in isolation SCREENS Veri fi cation of the screen states DESIGN SYSTEM Veri fi cation of all components from Design System
  3. FUNCTIONAL TESTS FAKE DATA Usually fake data used to display

    data on the screen TESTING IN ISOLATION Usually screen tested in isolation
  4. END-TO-END TESTS ENTRY POINT Similar to the entry point of

    the app NAVIGATION Navigate to the required screen SERVER INTERACTION Interaction with the prod server
  5. 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( MainActivity::class.java )
  6. 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)
  7. launchActivity launchFragmentInContainer ComposeContentTestRule @get:Rule val composeTestRule = createComposeRule() composeTestRule.apply {

    setContent { LoginScreen() } onNodeWithText("Enter your email") .performTextInput("[email protected]") onNodeWithText("Enter you password") .performTextInput("password") onNodeWithText("Login") .performClick() ... }
  8. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

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

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

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

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasText("Enter your email")) .performTextInput("[email protected]") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher Action Assertion
  12. onNode( hasText("Schedule a task"), useUnmergedTree = true ).assertHasClickAction() Window Button

    Text Image Window Button onNode(hasText("Schedule a task")) .assertHasClickAction() MERGED NODE TREE UNMERGED NODE TREE
  13. Window Button Window Button Text Image onNode(hasText("Schedule a task")) .assertHasClickAction()

    onNode( hasText("Schedule a task"), useUnmergedTree = true ).assertHasClickAction() MERGED NODE TREE UNMERGED NODE TREE
  14. LAYOUT INSPECTOR REPLACE IT WITH IMAGE OF LAYOUT INSPECTOR VIEW

    & COMPOSABLE Information about Views and Composables DETAILED INFO Detail information & semantic properties values RESOURCE INFO Information about String resources
  15. PRINT TO LOGS Node #1 at (l=0.0, t=63.0, r=1080.0, b=2274.0)px

    |-Node #4 at (l=0.0, t=63.0, r=1080.0, b=2274.0)px IsTraversalGroup = 'true' |-Node #19 at (l=0.0, t=63.0, r=1080.0, b=2274.0)px | IsTraversalGroup = 'true' | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)' | Actions = [ScrollBy] | |-Node #22 at (l=42.0, t=620.0, r=1038.0, b=788.0)px | | ImeAction = 'Default' | | EditableText = '' | | TextSelectionRange = 'TextRange(0, 0)' | | Focused = 'false' | | ContentDescription = '[Email]' | | Text = '[Email]' | | Actions = [GetTextLayoutResult, SetText, …] | | MergeDescendants = 'true' | |-Node #34 at (l=42.0, t=809.0, r=1038.0, b=977.0)px | | ImeAction = 'Default' | | EditableText = '' | | TextSelectionRange = 'TextRange(0, 0)' | | Focused = 'false' | | ContentDescription = '[Password]' | | Text = '[Password]' | | [Password] | | Actions = [GetTextLayoutResult, SetText, …] | | MergeDescendants = 'true' | | |-Node #41 at (l=923.0, t=852.0, r=1028.0, b=957.0)px | | Role = 'Button' | | Focused = 'false' | | ContentDescription = '[Show password]' | | Actions = [OnClick, RequestFocus] | | MergeDescendants = 'true' | |-Node #48 at (l=42.0, t=1009.0, r=1038.0, b=1114.0)px | Focused = 'false' | Role = 'Button' | Text = '[LOGIN]' | Actions = [OnClick, RequestFocus, GetTextLayoutResult] | MergeDescendants = 'true' |-Node #7 at (l=0.0, t=63.0, r=1080.0, b=231.0)px IsTraversalGroup = 'true' |-Node #11 at (l=43.0, t=108.0, r=184.0, b=185.0)px Text = '[Login]' Actions = [GetTextLayoutResult] Node #1 at (l=0.0, t=63.0, r=1080.0, b=2274.0)px |-Node #4 at (l=0.0, t=63.0, r=1080.0, b=2274.0)px IsTraversalGroup = 'true' |-Node #19 at (l=0.0, t=63.0, r=1080.0, b=2274.0)px | IsTraversalGroup = 'true' | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)' | Actions = [ScrollBy] | |-Node #22 at (l=42.0, t=620.0, r=1038.0, b=788.0)px | | ImeAction = 'Default' | | EditableText = '' | | TextSelectionRange = 'TextRange(0, 0)' | | Focused = 'false' | | Actions = [GetTextLayoutResult, SetText, …] | | MergeDescendants = 'true' | | |-Node #23 at (l=42.0, t=641.0, r=1038.0, b=788.0)px | | |-Node #27 at (l=74.0, t=684.0, r=137.0, b=747.0)px | | | ContentDescription = '[Email]' | | | Role = 'Image' | | |-Node #32 at (l=179.0, t=686.0, r=291.0, b=743.0)px | | Text = '[Email]' | | Actions = [GetTextLayoutResult] | |-Node #34 at (l=42.0, t=809.0, r=1038.0, b=977.0)px | | ImeAction = 'Default' | | EditableText = '' | | TextSelectionRange = 'TextRange(0, 0)' | | Focused = 'false' | | [Password] | | Actions = [GetTextLayoutResult, SetText, …] | | MergeDescendants = 'true' | | |-Node #35 at (l=42.0, t=830.0, r=1038.0, b=977.0)px | | |-Node #39 at (l=74.0, t=873.0, r=137.0, b=936.0)px | | | ContentDescription = '[Password]' | | | Role = 'Image' | | |-Node #41 at (l=923.0, t=852.0, r=1028.0, b=957.0)px | | | Role = 'Button' | | | Focused = 'false' | | | Actions = [OnClick, RequestFocus] | | | MergeDescendants = 'true' | | | |-Node #42 at (l=944.0, t=873.0, r=1007.0, b=936.0)px | | | ContentDescription = '[Show password]' | | | Role = 'Image' | | |-Node #47 at (l=179.0, t=875.0, r=378.0, b=932.0)px | | Text = '[Password]' | | Actions = [GetTextLayoutResult] | |-Node #48 at (l=42.0, t=1009.0, r=1038.0, b=1114.0)px | Focused = 'false' | Role = 'Button' | Actions = [OnClick, RequestFocus] | MergeDescendants = 'true' | |-Node #52 at (l=497.0, t=1037.0, r=605.0, b=1086.0)px | Text = '[LOGIN]' | Actions = [GetTextLayoutResult] |-Node #7 at (l=0.0, t=63.0, r=1080.0, b=231.0)px IsTraversalGroup = 'true' |-Node #11 at (l=43.0, t=108.0, r=184.0, b=185.0)px Text = '[Login]' Actions = [GetTextLayoutResult] onRoot(useUnmergedTree = true) .printToLog("UNMERGED") onRoot() .printToLog("MERGED")
  16. Node #1 at (l=0.0, t=63.0, r=1080.0, b=1186.0)px |-Node #3 at

    (l=0.0, t=194.0, r=1080.0, b=1046.0)px | IsTraversalGroup = 'true' | ... | Actions = [IndexForKey, ScrollBy, ScrollToIndex] | |-Node #11 at (l=231.0, t=215.0, r=928.0, b=271.0)px | | Text = '[Americano]' | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | |-Node #12 at (l=231.0, t=282.0, r=928.0, b=454.0)px | | Text = '[Americano is a type of coffee ...]’ | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | |-Node #13 at (l=949.0, t=280.0, r=1059.0, b=390.0)px | | IsTraversalGroup = 'true' | | |-Node #15 at (l=950.0, t=291.0, r=1055.0, b=375.0)px | | Focused = 'false' | | Role = 'Button' | | Text = '[+]' | | Actions = [OnClick, RequestFocus, SetTextSubstitution,...] | | MergeDescendants = 'true' | | ... |-Node #5 at (l=42.0, t=1088.0, r=1038.0, b=1144.0)px Text = '[Pay (€ 0)]' Actions = [SetTextSubstitution, ShowTextSubstitution, ...] PRINT TO LOGS
  17. THE “SEMANTICS” MATCHER Node #1 at (l=0.0, t=63.0, r=1080.0, b=1186.0)px

    |-Node #3 at (l=0.0, t=194.0, r=1080.0, b=1046.0)px | IsTraversalGroup = 'true' | ... | Actions = [IndexForKey, ScrollBy, ScrollToIndex] | |-Node #8 at (l=21.0, t=215.0, r=1059.0, b=454.0)px | | |-Node #11 at (l=231.0, t=215.0, r=928.0, b=271.0)px | | | Text = '[Americano]' | | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | | |-Node #12 at (l=231.0, t=282.0, r=928.0, b=454.0)px | | | Text = '[Americano is a type of coffee ...]' | | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | | |-Node #13 at (l=949.0, t=280.0, r=1059.0, b=390.0)px | | IsTraversalGroup = 'true' | | |-Node #15 at (l=950.0, t=291.0, r=1055.0, b=375.0)px | | Focused = 'false' | | Role = 'Button' | | Text = '[+]' | | Actions = [OnClick, RequestFocus, SetTextSubstitution, ...] | | MergeDescendants = 'true' | | ... |-Node #5 at (l=42.0, t=1088.0, r=1038.0, b=1144.0)px Text = '[Pay (€ 0)]' Actions = [SetTextSubstitution, ShowTextSubstitution, ...] Modifier.semantics(mergeDescendants = false) {}
  18. Node #1 at (l=0.0, t=63.0, r=1080.0, b=1186.0)px |-Node #3 at

    (l=0.0, t=194.0, r=1080.0, b=1046.0)px | IsTraversalGroup = 'true' | ... | Actions = [IndexForKey, ScrollBy, ScrollToIndex] | |-Node #8 at (l=21.0, t=215.0, r=1059.0, b=454.0)px | | |-Node #11 at (l=231.0, t=215.0, r=928.0, b=271.0)px | | | Text = '[Americano]' | | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | | |-Node #12 at (l=231.0, t=282.0, r=928.0, b=454.0)px | | | Text = '[Americano is a type of coffee ...]' | | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | | |-Node #13 at (l=949.0, t=280.0, r=1059.0, b=390.0)px | | IsTraversalGroup = 'true' | | |-Node #15 at (l=950.0, t=291.0, r=1055.0, b=375.0)px | | Focused = 'false' | | Role = 'Button' | | Text = '[+]' | | Actions = [OnClick, RequestFocus, SetTextSubstitution, ...] | | MergeDescendants = 'true' | | ... |-Node #5 at (l=42.0, t=1088.0, r=1038.0, b=1144.0)px Text = '[Pay (€ 0)]' Actions = [SetTextSubstitution, ShowTextSubstitution, ...] fun withIncrementForCoffeeDrink(text: String): SemanticsMatcher { return hasText("+") .and(hasAnyAncestor(hasAnyChild(hasText(text)))) } Modifier.semantics(mergeDescendants = false) {} THE “SEMANTICS” MATCHER
  19. IT CAN CHANGE BEHAVIOR OF TALK BACK Node #1 at

    (l=0.0, t=63.0, r=1080.0, b=1186.0)px |-Node #3 at (l=0.0, t=194.0, r=1080.0, b=1046.0)px | IsTraversalGroup = 'true' | ... | Actions = [IndexForKey, ScrollBy, ScrollToIndex] | |-Node #8 at (l=21.0, t=215.0, r=1059.0, b=454.0)px | | |-Node #11 at (l=231.0, t=215.0, r=928.0, b=271.0)px | | | Text = '[Americano]' | | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | | |-Node #12 at (l=231.0, t=282.0, r=928.0, b=454.0)px | | | Text = '[Americano is a type of coffee ...]' | | | Actions = [SetTextSubstitution, ShowTextSubstitution, ...] | | |-Node #13 at (l=949.0, t=280.0, r=1059.0, b=390.0)px | | IsTraversalGroup = 'true' | | |-Node #15 at (l=950.0, t=291.0, r=1055.0, b=375.0)px | | Focused = 'false' | | Role = 'Button' | | Text = '[+]' | | Actions = [OnClick, RequestFocus, SetTextSubstitution, ...] | | MergeDescendants = 'true' | | ... |-Node #5 at (l=42.0, t=1088.0, r=1038.0, b=1144.0)px Text = '[Pay (€ 0)]' Actions = [SetTextSubstitution, ShowTextSubstitution, ...] Modifier.semantics(mergeDescendants = false) {} THE “SEMANTICS” MATCHER
  20. “Plus” button Add Americano to the basket @ExperimentalFoundationApi class CoffeeDrinksTest

    { @get:Rule val composeTestRule = createComposeRule() @Test fun verifyPriceForTwoAmericanoAndOneEspressoByCoffeeDrinkName() { composeTestRule.apply { setContent { CoffeeDrinksWithBasket(drinks = coffeeDrinks) } onNodeWithContentDescription("Add Americano to the basket") .performClick() .performClick() onNodeWithContentDescription("Add Espresso to the basket") .performClick() onNode(hasText("Pay (€ 18.5)”)) .assertIsDisplayed() } } }
  21. CUSTOM MATCHERS 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) } ... onNode(hasHorizontalScroll()) .performTouchInput { swipeLeft() } onNodeWithText("Chapter 2") .assertIsDisplayed() ... } } fun hasHorizontalScroll() = SemanticsMatcher.keyIsDefined( SemanticsProperties.HorizontalScrollAxisRange ) }
  22. ACCESS TO STRING RESOURCES class SwitchTest { @get:Rule val composeTestRule

    = createAndroidComposeRule<ComponentActivity>() @Test fun shouldSubItemsBeEnabled_whenParentItemIsChecked() { composeTestRule.apply { setContent { SettingsScreen() } onSettingSwitchItem( getString(R.string.demoSwitchSettings_super_important_item_title), getString(R.string.demoSwitchSettings_super_important_item_description), ).performClick() ... } } private fun getString(@StringRes stringId: Int): String = composeTestRule.activity.getString(stringId) private fun SemanticsNodeInteractionsProvider.onSettingSwitchItem( title: String, description: String ) = onNode( SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Switch) .and(hasText(title)) .and(hasText(description)) ) }
  23. WAIT FOR RESULT composableTestRule.apply { // IdlingResources registerIdlingResource(...) unregisterIdlingResource(...) //

    waitUntil waitUntil { onAllNodesWithText("Emotions") .fetchSemanticsNodes().size == 1 } onNode(hasContentDescription("Add")) .performClick() }
  24. class AnimationTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    coffeeDrinkAnimation() { composeTestRule.apply { setContent { CoffeeDrinkAnimationBox() } mainClock.autoAdvance = false compareScreenshot(composeTestRule, "test-state-0") mainClock.advanceTimeBy(150) compareScreenshot(composeTestRule, "test-state-1") mainClock.advanceTimeBy(200) compareScreenshot(composeTestRule, "test-state-2") mainClock.advanceTimeBy(400) compareScreenshot(composeTestRule, "test-state-3") ... } } }
  25. SHOT @OptIn(ExperimentalMaterial3Api::class) class WeekCalendarTestHarness : ScreenshotTest { private val testDate

    = LocalDate.of(2022, 5, 5) @get:Rule val composeTestRule = createComposeRule() @Test fun weekCalendar_dark_defaultState() { composeTestRule.apply { setContent { AppTheme(darkTheme = true) { WeekCalendar( startDate = testDate.minusDays(6), selectedDate = testDate, onSelectedDateChange = {}, todayDate = testDate ) } } compareScreenshot( composeTestRule, “weekCalendar_dark" ) } } }
  26. SHOT @OptIn(ExperimentalMaterial3Api::class) class WeekCalendarTestHarness : ScreenshotTest { private val testDate

    = LocalDate.of(2022, 5, 5) @get:Rule val composeTestRule = createComposeRule() @Test fun weekCalendar_dark_defaultState() { composeTestRule.apply { setContent { AppTheme(darkTheme = true) { WeekCalendar( startDate = testDate.minusDays(6), selectedDate = testDate, onSelectedDateChange = {}, todayDate = testDate ) } } compareScreenshot( composeTestRule, “weekCalendar_dark" ) } } } @OptIn(ExperimentalMaterial3Api::class) class WeekCalendarScreenshotTest { private val testDate = LocalDate.of(2022, 5, 5) @get:Rule val paparazzi = Paparazzi( deviceConfig = DeviceConfig.NEXUS_5 .copy(softButtons = false), renderingMode = SessionParams.RenderingMode.SHRINK ) @Test fun weekCalendar_dark_defaultState() { paparazzi.snapshot("weekCalendar_dark") { AppTheme(darkTheme = true) { WeekCalendar( startDate = testDate.minusDays(6), selectedDate = testDate, onSelectedDateChange = {}, todayDate = testDate ) } } } } PAPARAZZI
  27. @Test fun weekCalendar_dark_hugeFontScale() { composeTestRule.apply { setContent { AppTheme(darkTheme =

    true) { TestHarness(fontScale = 1.3f) { WeekCalendar( startDate = testDate.minusDays(6), selectedDate = testDate, onSelectedDateChange = {}, todayDate = testDate ) } } } compareScreenshot( composeTestRule, "weekCalendar_dark_hugeFontScale" ) } } @Test fun weekCalendar_light_hugeFontScale() { composeTestRule.apply { setContent { AppTheme(darkTheme = false) { TestHarness(fontScale = 1.3f) { WeekCalendar( startDate = testDate.minusDays(6), selectedDate = testDate, onSelectedDateChange = {}, todayDate = testDate ) } } } compareScreenshot( composeTestRule, "weekCalendar_light_hugeFontScale" ) } }
  28. @RunWith(TestParameterInjector::class) class WeekCalendarTestHarness : ScreenshotTest { private val testDate =

    LocalDate.of(2022, 5, 5) @get:Rule val composeTestRule = createComposeRule() @Test fun weekCalendar_light_hugeFontScale( @TestParameter isDarkMode: Boolean, @TestParameter fontScale: FontScale ) { val uiModeDesc = if (isDarkMode) "dark" else "light" composeTestRule.apply { setContent { AppTheme(darkTheme = isDarkMode) { TestHarness(fontScale = fontScale.value) { WeekCalendar( startDate = testDate.minusDays(6), selectedDate = testDate, onSelectedDateChange = {}, todayDate = testDate ) } } } compareScreenshot( composeTestRule, "weekCalendar_${uiModeDesc}_$fontScale" ) } } } enum class FontScale(val value: Float) { SMALL(0.85f), NORMAL(1f), LARGE(1.15f), HUGE(1.3f); override fun toString(): String { return when (this) { SMALL -> "smallFontScale" NORMAL -> "normalFontScale" LARGE -> "largeFontScale" HUGE -> "hugeFontScale" } } }
  29. END-TO-END TESTS ENTRY POINT Similar to the entry point of

    the app NAVIGATION Navigate to the required screen SERVER INTERACTION Interaction with the prod server
  30. FAKE DATA Usually fake data used to display data on

    the screen Usually screen tested in isolation TESTING IN ISOLATION FUNCTIONAL TESTS END-TO-END TESTS ENTRY POINT Similar to the entry point of the app NAVIGATION Navigate to the required screen SERVER INTERACTION Interaction with the prod server
  31. END-TO-END TESTS FAKE DATA Usually fake data used to display

    data on the screen Usually screen tested in isolation TESTING IN ISOLATION FUNCTIONAL TESTS ENTRY POINT Similar to the entry point of the app NAVIGATION Navigate to the required screen SERVER INTERACTION Interaction with the prod server UI COMPONENTS Veri fi cation of UI components in isolation SCREENS Veri fi cation of the screen states DESIGN SYSTEM Veri fi cation of all components from Design System SCREENSHOT TESTS