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

Workshop: Android UI Testing

Workshop: Android UI Testing

Mobile apps are growing, we have new features after every big release. Testing the app manually is time consuming. It means that it is time to integrate fast and reliable automated tests into your development process.

In this talk, we will discuss the following topics:
– How to create fast and reliable UI tests
– How to avoid flaky tests
– How to cover applications which include traditional and Jetpack Compose views and screens
– How to share UI tests between local and instrumentation tests
– How DSL can speed up adding stable UI tests to the project
– How to combine interaction and pixel-perfectness tests
– What tools to use for effective testing of different application components

Alex Zhukovich

May 10, 2023
Tweet

More Decks by Alex Zhukovich

Other Decks in Programming

Transcript

  1. 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 )
  2. 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"
  3. 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"
  4. 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() } } }
  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() } } } Finder Matcher
  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 Action
  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 Assertion
  8. 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]
  9. 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")
  10. 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' composeTestRule .onRoot(useUnmergedTree = false) 
 .printToLog("MERGED") composeTestRule .onRoot(useUnmergedTree = true) .printToLog("UNMERGED")
  11. @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() ) } } } }
  12. @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() ) } } } }
  13. @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)) ) }
  14. @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)) ) }
  15. Prod server Dev server DATA SYNCHRONIZATION We need to synchronize

    data between the production and dev servers. INTERACTION WITH THE SERVER We make requests to the production server. The connection can require certificates, VPN, etc. PRODUCTION BACK-END We always use the latest available production environment.
  16. Mock/Fake DATA SYNCHRONIZATION We need to synchronize predefined responses with

    responses from the production server. NO INTERACTION WITH THE SERVER Responses from the production server can differ from the predefined data. We can use predefined fake responses instead of calling the production server. MAKE TESTS MORE STABLE
  17. END-TO-END (E2E) TESTS FUNCTIONAL TESTS ENTRY POINT Similar to the

    entry point of an app. APP VERIFICATION Slow veri fi cation of the app. NAVIGATION Navigate to the required screen. SERVER INTERACTION Interaction with the production server. ENTRY POINT Start the required screen of the app. SERVER INTERACTION Usually interaction with non- production server. NAVIGATION Usually no navigation. UI VERIFICATION Fast veri fi cation of components or screens.
  18. @Composable fun LoadingButton( onClick: () -> Unit, text: String, modifier:

    Modifier = Modifier, shape: Shape = MaterialTheme.shapes.small, enabled: Boolean = true, isLoading: Boolean = false, loadingIndicatorColor: Color = MaterialTheme.colorScheme.surface ) { ... }
  19. @Test fun settingsScreen_dark_defaultState() { val fragmentScenario = FragmentScenarioConfigurator .setUiMode(UiMode.NIGHT) .setTheme(R.style.Theme_MoodTracker)

    .launchInContainer( SettingsFragment::class.java ) compareScreenshot( fragment = fragmentScenario.waitForFragment(), name = "settingsScreen_dark" ) fragmentScenario.close() } @Test fun settingsScreen_light_defaultState() { val fragmentScenario = FragmentScenarioConfigurator .setUiMode(UiMode.DAY) .setTheme(R.style.Theme_MoodTracker) .launchInContainer( SettingsFragment::class.java ) compareScreenshot( fragment = fragmentScenario.waitForFragment(), name = "settingsScreen_light" ) fragmentScenario.close() }
  20. 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
  21. appId: com.alexzh.moodtracker --- - launchApp: clearState: true - tapOn: "Add"

    - tapOn: "Excited" - tapOn: "Select a time" - tapOn: "8" - tapOn: "15" - tapOn: "OK" - tapOn: "Gaming" - tapOn: "Save" - assertVisible: "08:15" - assertVisible: "Gaming" @Test fun displayEmotion_WhenEmotionHistoryWasAddedViaAddMoodScreen() { ActivityScenario.launch(HomeActivity::class.java) composableTestRule.apply { waitUntil { onAllNodesWithText("Emotions") .fetchSemanticsNodes().size == 1 } onNode(hasContentDescription("Add")) .performClick() waitUntil { onAllNodesWithContentDescription("Happy") .fetchSemanticsNodes().size == 1 } onNodeWithContentDescription("Happy") .performClick() onNodeWithText("Reading") .performClick() onNodeWithText("Gaming") .performClick() onNodeWithText("Note") .performTextInput("Test note") onNode(hasText("Save")) .performScrollTo() .performClick() waitUntil { onAllNodesWithText("Emotions") .fetchSemanticsNodes().size == 1 } onNode(withEmotionStateAndNote("Happy", "Test note")) .assert(hasAnyChild(hasText("Gaming"))) onNode(hasContentDescription("Happy")) .performSemanticsAction(SemanticsActions.OnClick) } }
  22. appId: com.alexzh.moodtracker --- - launchApp: clearState: true - tapOn: "Add"

    - tapOn: "Excited" - tapOn: "Select a time" - tapOn: "8" - tapOn: "15" - tapOn: "OK" - tapOn: "Gaming" - tapOn: "Save" - assertVisible: "08:15" - assertVisible: "Gaming" launchApp() todayScreen { addEmotionalState() } addMoodScreen { selectEmotion("Happy") selectActivity("Reading", "Gaming") enterNote(note) save() } todayScreen { hasItem( "Happy", “Test note”, "Reading", "Gaming" ) openEmotionalItem("Happy") } addMoodScreen { delete() }
  23. @Test fun displayEmotion_WhenEmotionHistoryWasAddedViaAddMoodScreen() { ActivityScenario.launch(HomeActivity::class.java) composableTestRule.apply { waitUntil { onAllNodesWithText("Emotions")

    .fetchSemanticsNodes().size == 1 } onNode(hasContentDescription("Add")) .performClick() waitUntil { onAllNodesWithContentDescription("Happy") .fetchSemanticsNodes().size == 1 } onNodeWithContentDescription("Happy") .performClick() onNodeWithText("Reading") .performClick() onNodeWithText("Gaming") .performClick() onNodeWithText("Note") .performTextInput("Test note") onNode(hasText("Save")) .performScrollTo() .performClick() waitUntil { onAllNodesWithText("Emotions") .fetchSemanticsNodes().size == 1 } onNode(withEmotionStateAndNote("Happy", "Test note")) .assert(hasAnyChild(hasText("Gaming"))) onNode(hasContentDescription("Happy")) .performSemanticsAction(SemanticsActions.OnClick) } } launchApp() todayScreen { addEmotionalState() } addMoodScreen { selectEmotion("Happy") selectActivity("Reading", "Gaming") enterNote(note) save() } todayScreen { hasItem( "Happy", note, "Reading", "Gaming" ) openEmotionalItem("Happy") } addMoodScreen { delete() }
  24. END-TO-END (E2E) TESTS FUNCTIONAL TESTS ENTRY POINT Similar to the

    entry point of an app. APP VERIFICATION Slow veri fi cation of the app. NAVIGATION Navigate to the required screen. SERVER INTERACTION Interaction with the production server. ENTRY POINT Start the required screen of the app. SERVER INTERACTION Usually interaction with non- production server. NAVIGATION Usually no navigation. UI VERIFICATION Fast veri fi cation of components or screens.
  25. // Profile screen onNodeWithText("Login") .performClick() // Login screen onNodeWithText("Email") .performTextInput("[email protected]")

    onNodeWithText("Password") .performTextInput("password") onNode(hasText("LOGIN")) .performClick() // Profile screen onNodeWithText("Alex") .assertIsDisplayed() onNodeWithText("[email protected]") .assertIsDisplayed()
  26. profileScreen(composeTestRule) { tapOnLogin() } loginScreen(composeTestRule) { login( email = "[email protected]",

    password = "password" ) } profileScreen(composeTestRule) { hasUserInfo( email = "[email protected]", name = "Alex" ) }
  27. BASIC OPERATIONS SCREEN ROBOTS TEST CASES @RunWith(AndroidJUnit4::class) class LoginActivityTest {

    @Test fun shouldBeDisplayedEmptyEmailErrorsWhenValueIsEmpty() { loginScreen { enterEmail(EMPTY_VALUE) emptyEmailErrorDisplayed() } } @Test fun shouldBeDisplayedEmailErrorsWhenValueIsNotEmail() { loginScreen { enterEmail(INCORRECT_EMAIL) incorrectEmailErrorDisplayed() } } @Test fun shouldBeDisplayedEmptyPasswordErrorsWhenValueIsEmpty() { loginScreen { enterPassword(EMPTY_VALUE) emptyPasswordErrorDisplayed() } } ... } class LoginScreenRobot : BaseTestRobot() { fun enterEmail(email: String) = enterText(R.id.email, email) fun enterPassword(email: String) = 
 enterText(R.id.password, password) ... } open class BaseTestRobot { fun enterText(viewId: Int, text: String) { onView(withId(viewId)) .perform(replaceText(text)) } fun clickOnView(viewId: Int) { onView(withId(viewId)) .perform(click()) } 
 ... } ESPRESSO
  28. BASIC OPERATIONS SCREEN ROBOTS TEST CASES @RunWith(AndroidJUnit4::class) class LoginActivityTest {

    @Test fun shouldBeDisplayedEmptyEmailErrorsWhenValueIsEmpty() { loginScreen { enterEmail(EMPTY_VALUE) emptyEmailErrorDisplayed() } } @Test fun shouldBeDisplayedEmailErrorsWhenValueIsNotEmail() { loginScreen { enterEmail(INCORRECT_EMAIL) incorrectEmailErrorDisplayed() } } @Test fun shouldBeDisplayedEmptyPasswordErrorsWhenValueIsEmpty() { loginScreen { enterPassword(EMPTY_VALUE) emptyPasswordErrorDisplayed() } } ... } class LoginScreenRobot : BaseTestRobot() { fun enterEmail(email: String) = enterText(R.id.email, email) fun enterPassword(email: String) = 
 enterText(R.id.password, password) ... } open class BaseTestRobot { fun enterText(viewId: Int, text: String) { val view = device.findObject(By.res(resId(viewId))) view.text = text } fun clickOnView(viewId: Int) { device.findObject(By.res(resIf(viewId))) 
 .click() } 
 ... } UI AUTOMATOR
  29. open class BaseTestRobot { @get:Rule val composeTestRule = createComposeRule() fun

    enterText(tag: String, text: String) { composeTestRule.onNodeWithTag(tag) .performTextInput(text) } fun clickOnView(tag: String) { composeTestRule.onNodeWithTag(tag) .performClick() } } @RunWith(AndroidJUnit4::class) class LoginActivityTest { @Test fun shouldBeDisplayedEmptyEmailErrorsWhenValueIsEmpty() { loginScreen { enterEmail(EMPTY_VALUE) emptyEmailErrorDisplayed() } } @Test fun shouldBeDisplayedEmailErrorsWhenValueIsNotEmail() { loginScreen { enterEmail(INCORRECT_EMAIL) incorrectEmailErrorDisplayed() } } @Test fun shouldBeDisplayedEmptyPasswordErrorsWhenValueIsEmpty() { loginScreen { enterPassword(EMPTY_VALUE) emptyPasswordErrorDisplayed() } } ... } BASIC OPERATIONS SCREEN ROBOTS TEST CASES COMPOSE UI TESTS class LoginScreenRobot : BaseTestRobot() { fun enterEmail(email: String) = enterText("email", email) fun enterPassword(email: String) = 
 enterText("password", password) ... }
  30. Test case execution - Simulate User actions - Incorrect state

    before/after a test case - Toast, Snackbar, etc
  31. - Network connection (VPN) - Network speed - Back-end -

    Framework issues - Toast, Snackbar, etc - Custom Views - Simulate User actions - Incorrect state before/after a test case - Toast, Snackbar, etc - Performance - Noti fi cations - Device memory External dependencies Framework Test case execution Device and emulator