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

UI Testing of Jetpack Compose Apps, AppDevCon

UI Testing of Jetpack Compose Apps, AppDevCon

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 to building UI.

2b0404a5db1a74f01bf3bf94d142e28c?s=128

Alex Zhukovich

June 24, 2022
Tweet

More Decks by Alex Zhukovich

Other Decks in Programming

Transcript

  1. @alex_zhukovich https://alexzh.com/ UI Testing 
 Jetpack Compose apps

  2. None
  3. Good Test Case

  4. STABLE FAST READABLE INDEPENDENT

  5. Interaction Pixel perfectness

  6. INTERACTION PIXEL PERFECTNESS

  7. INTERACTION PIXEL PERFECTNESS

  8. 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
  9. SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

  10. FUNCTIONAL TESTS END TO END TESTS SCREENSHOT TESTS

  11. FUNCTIONAL TESTS END TO END TESTS SCREENSHOT TESTS

  12. FUNCTIONAL TESTS END TO END TESTS SCREENSHOT TESTS

  13. END-TO-END TESTS FUNCTIONAL TESTS SCREENSHOT TESTS

  14. State of Android Dev & Testing

  15. ACTIVITY FRAGMENT

  16. ACTIVITY FRAGMENT COMPOSABLE FUNCTION

  17. launchActivity launchFragmentInContainer ComposeContentTestRule ActivityScenario.launch( MainActivity::class.java )

  18. 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(
  19. 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.1"
  20. launchActivity launchFragmentInContainer ComposeContentTestRule Activity Fragment

  21. launchActivity launchFragmentInContainer ComposeContentTestRule androidx.fragment.app.testing.FragmentScenario$EmptyFragmentActivity Activity Fragment Compilation Added to AndroidManifest

  22. launchActivity launchFragmentInContainer ComposeContentTestRule @get:Rule val composeTestRule = createComposeRule() composeTestRule.apply {

    setContent { LoginScreen() } onNodeWithTag("email") .performTextInput("test@test.com") onNodeWithTag("password") .performTextInput("password") onNodeWithTag("login") .performClick() ... } debugImplementation “androidx.compose.ui:ui-test-manifest:1:1:1"
  23. launchActivity launchFragmentInContainer ComposeContentTestRule Component Activity @Composable

  24. launchActivity launchFragmentInContainer ComposeContentTestRule androidx.activity.ComponentActivity Component Activity @Composable Compilation Added to

    AndroidManifest
  25. NO ID 
 OF COMPONENT SEMANTIC PROPERTIES VIRTUAL CLOCK

  26. Testing Composable code

  27. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

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

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

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

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("test@test.com") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher Action Assertion
  31. 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]
  32. 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' composeTestRule .onRoot(useUnmergedTree = false) 
 .printToLog("MERGED") composeTestRule .onRoot(useUnmergedTree = true) .printToLog("UNMERGED")
  33. 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")
  34. onAllNodesWithContentDescription("Happy")[0] .assert(hasText(“08:30")) onAllNodes(hasContentDescription("Happy"))[1] .assert(hasText("19:45")) onNodeWithText("Add") .performClick()

  35. @RunWith(AndroidJUnit4::class) class ProfileScreenTest: KoinTest { @get:Rule val composeTestRule = createComposeRule()

    ... 
 @Test fun displayUserInfo_WhenUserIsLoggedIn() { val email = "test@test.com" ... composeTestRule.apply { setContent { ProfileScreen(...) } onNodeWithText("Login") .performClick() onNodeWithText("Email") .performTextInput(email) onNodeWithText("Password") .performTextInput(password) onNode(hasText("LOGIN")) .performClick() onNodeWithText(email) .assertIsDisplayed() onNodeWithText(username) .assertIsDisplayed() } } } @RunWith(AndroidJUnit4::class) class ProfileScreenAndroidComposeTest: KoinTest { @get:Rule val composeTestRule = createAndroidComposeRule<ComponentActivity>() fun getString(@StringRes resId: Int) = composeTestRule.activity.getString(resId) 
 ... 
 @Test fun displayUserInfo_WhenUserIsLoggedIn() { val email = “test@test.com" ... 
 composeTestRule.apply { setContent { ProfileScreen(...) } 
 onNodeWithText(getString(R.string.profileScreen_login_button)) .performClick() onNodeWithText(getString(R.string.genericTextField_email_label)) .performTextInput(email) onNodeWithText(getString(R.string.genericTextField_password_label)) .performTextInput(password) onNodeWithText(getString(R.string.loginScreen_login_button)) .performClick() ... } } }
  36. None
  37. @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() ) } } } }
  38. @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() ) } } } }
  39. @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)) ) }
  40. @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)) ) }
  41. ... waitUntil { onAllNodesWithContentDescription("Happy") .fetchSemanticsNodes().size == 1 } onNodeWithContentDescription("Happy") .performClick()

    onNodeWithText("Reading") .performClick() onNodeWithText("Gaming") .performClick()
  42. ... waitUntil { onAllNodesWithContentDescription("Happy") .fetchSemanticsNodes().size == 1 } onNodeWithContentDescription("Happy") .performClick()

    onNodeWithText("Reading") .performClick() onNodeWithText("Gaming") .performClick()
  43. 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) }
  44. 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) }
  45. ENABLE/DISABLE SYNCHRONIZATION ADVANCE TIME BY A SPECIFIC DURATION IDLING RESOURCES,

    
 WAIT UNTIL
  46. 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") } } }
  47. None
  48. AndroidUiTestingUtils https://github.com/sergio-sastre/AndroidUiTestingUtils Locale Light/Dark mode Display size, screen orientation Font

    size
  49. TestParameterInjector https://github.com/google/TestParameterInjector Strings Enums Primitive Types

  50. @RunWith(TestParameterInjector::class) class SettingsScreenParamScreenshotTest : ScreenshotTest { @get:Rule val composeTestRule =

    createEmptyComposeRule() @Test fun settingsScreen_customFontSizeAndUiMode( @TestParameter fontSize: FontSize, @TestParameter uiMode: UiMode ) { val activityScenario = ActivityScenarioConfigurator.ForComposable() .setFontSize(fontSize) .setUiMode(uiMode) .launchConfiguredActivity() .onActivity { it.setContent { AppTheme { SettingsScreen( onProfile = {}, onDocs = {} ) } } } activityScenario.waitForActivity() compareScreenshot(composeTestRule, "settingsScreen_${uiMode}_${fontSize}_defaultState") activityScenario.close() } }
  51. @RunWith(TestParameterInjector::class) class SettingsScreenParamScreenshotTest : ScreenshotTest { @get:Rule val composeTestRule =

    createEmptyComposeRule() @Test fun settingsScreen_customFontSizeAndUiMode( @TestParameter fontSize: FontSize, @TestParameter uiMode: UiMode ) { val activityScenario = ActivityScenarioConfigurator.ForComposable() .setFontSize(fontSize) .setUiMode(uiMode) .launchConfiguredActivity() .onActivity { it.setContent { AppTheme { SettingsScreen( onProfile = {}, onDocs = {} ) } } } activityScenario.waitForActivity() compareScreenshot(composeTestRule, "settingsScreen_${uiMode}_${fontSize}_defaultState") activityScenario.close() } }
  52. None
  53. Testing hybrid apps

  54. COMBINE MULTIPLE FRAMEWORKS IN A TEST COMBINE DIFFERENT APPROACHES USE

    DSL (DOMAIN- SPECIFIC-LANGUAGE)
  55. Best Practices

  56. USE MULTIPLE SMALL TESTS INSTEAD OF ONE BIG TEST

  57. ALL TEST CASES SHOULD BE INDEPENDENT

  58. NO "SLEEP" IN TESTS

  59. LEARN YOUR TOOLS

  60. #ExploreMore UI Testing @alex_zhukovich https://alexzh.com/