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.

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

    View Slide

  2. View Slide

  3. Good Test Case

    View Slide

  4. STABLE FAST READABLE INDEPENDENT

    View Slide

  5. Interaction Pixel perfectness

    View Slide

  6. INTERACTION
    PIXEL PERFECTNESS

    View Slide

  7. INTERACTION
    PIXEL PERFECTNESS

    View Slide

  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

    View Slide

  9. SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

    View Slide

  10. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View Slide

  11. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View Slide

  12. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View Slide

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

    View Slide

  14. State of Android


    Dev & Testing

    View Slide

  15. ACTIVITY FRAGMENT

    View Slide

  16. ACTIVITY FRAGMENT
    COMPOSABLE
    FUNCTION

    View Slide

  17. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    ActivityScenario.launch(


    MainActivity::class.java


    )


    View Slide

  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(


    View Slide

  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"

    View Slide

  20. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    Activity
    Fragment

    View Slide

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

    View Slide

  22. 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"

    View Slide

  23. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    Component
    Activity
    @Composable

    View Slide

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

    View Slide

  25. NO ID

    OF COMPONENT
    SEMANTIC
    PROPERTIES
    VIRTUAL CLOCK

    View Slide

  26. Testing Composable code

    View Slide

  27. 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()


    }


    }


    }


    View Slide

  28. 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

    View Slide

  29. 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

    View Slide

  30. 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

    View Slide

  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]

    View Slide

  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")


    View Slide

  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")


    View Slide

  34. onAllNodesWithContentDescription("Happy")[0]


    .assert(hasText(“08:30"))


    onAllNodes(hasContentDescription("Happy"))[1]


    .assert(hasText("19:45"))


    onNodeWithText("Add")


    .performClick()

    View Slide

  35. @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()


    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()


    ...


    }


    }


    }


    View Slide

  36. View Slide

  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()


    )


    }


    }


    }


    }


    View Slide

  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()


    )


    }


    }


    }


    }


    View Slide

  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))


    )


    }


    View Slide

  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))


    )


    }


    View Slide

  41. ...


    waitUntil {


    onAllNodesWithContentDescription("Happy")


    .fetchSemanticsNodes().size == 1


    }


    onNodeWithContentDescription("Happy")


    .performClick()


    onNodeWithText("Reading")


    .performClick()


    onNodeWithText("Gaming")


    .performClick()


    View Slide

  42. ...


    waitUntil {


    onAllNodesWithContentDescription("Happy")


    .fetchSemanticsNodes().size == 1


    }


    onNodeWithContentDescription("Happy")


    .performClick()


    onNodeWithText("Reading")


    .performClick()


    onNodeWithText("Gaming")


    .performClick()


    View Slide

  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)


    }


    View Slide

  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)


    }


    View Slide

  45. ENABLE/DISABLE
    SYNCHRONIZATION
    ADVANCE TIME BY A
    SPECIFIC DURATION
    IDLING RESOURCES,

    WAIT UNTIL

    View Slide

  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")


    }


    }


    }

    View Slide

  47. View Slide

  48. AndroidUiTestingUtils
    https://github.com/sergio-sastre/AndroidUiTestingUtils
    Locale Light/Dark


    mode
    Display size,


    screen orientation
    Font size

    View Slide

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

    View Slide

  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()


    }


    }

    View Slide

  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()


    }


    }

    View Slide

  52. View Slide

  53. Testing hybrid apps

    View Slide

  54. COMBINE MULTIPLE
    FRAMEWORKS IN A
    TEST
    COMBINE DIFFERENT
    APPROACHES
    USE DSL (DOMAIN-
    SPECIFIC-LANGUAGE)

    View Slide

  55. Best Practices

    View Slide

  56. USE MULTIPLE SMALL TESTS
    INSTEAD OF ONE BIG TEST

    View Slide

  57. ALL TEST CASES SHOULD BE
    INDEPENDENT

    View Slide

  58. NO "SLEEP" IN TESTS

    View Slide

  59. LEARN YOUR TOOLS

    View Slide

  60. #ExploreMore


    UI Testing
    @alex_zhukovich https://alexzh.com/

    View Slide