Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

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.

Alex Zhukovich

June 24, 2022
Tweet

More Decks by Alex Zhukovich

Other Decks in Programming

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.1"
  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:1:1"
  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' composeTestRule .onRoot(useUnmergedTree = false) 
 .printToLog("MERGED") composeTestRule .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. @RunWith(AndroidJUnit4::class) class ProfileScreenTest: KoinTest { @get:Rule val composeTestRule = createComposeRule()

    ... 
 @Test fun displayUserInfo_WhenUserIsLoggedIn() { val email = "[email protected]" ... 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 = “[email protected]" ... 
 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() ... } } }
  13. @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() ) } } } }
  14. @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() ) } } } }
  15. @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)) ) }
  16. @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)) ) }
  17. 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) }
  18. 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) }
  19. 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") } } }
  20. @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() } }
  21. @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() } }