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

UI Testing of Jetpack Compose apps - Droidcon Italy 2023

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. @alex_zhukovich https://alexzh.com/
    UI TESTING
    JETPACK COMPOSE

    View full-size slide

  2. PIXEL PERFECTNESS


    INTERACTION


    View full-size slide

  3. 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 full-size slide

  4. SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

    View full-size slide

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

    View full-size slide

  6. FUNCTIONAL TESTS


    FAKE DATA
    Usually fake data used to
    display data on the screen
    TESTING IN ISOLATION
    Usually screen tested in
    isolation

    View full-size slide

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

    View full-size slide

  8. ACTIVITY FRAGMENT

    View full-size slide

  9. ACTIVITY FRAGMENT
    COMPOSABLE
    FUNCTION

    View full-size slide

  10. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    ActivityScenario.launch(


    MainActivity::class.java


    )


    View full-size slide

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


    )

    View full-size slide

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


    View full-size slide

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


    ...


    }

    View full-size slide

  14. NO ID

    OF COMPONENT
    SEMANTIC
    PROPERTIES
    VIRTUAL CLOCK

    View full-size slide

  15. INTERACTION TESTS

    View full-size slide

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


    }


    }


    }


    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  20. COMPOSE TEST RULE ANDROID COMPOSE
    TEST RULE
    EMPTY COMPOSE TEST
    RULE

    View full-size slide

  21. MERGED NODE TREE


    UNMERGED NODE TREE


    View full-size slide

  22. Window
    Button
    MERGED NODE TREE


    UNMERGED NODE TREE


    View full-size slide

  23. Window
    Button
    MERGED NODE TREE


    UNMERGED NODE TREE


    Window
    Button
    Text
    Image

    View full-size slide

  24. Window
    Button
    onNode(hasText("Schedule a task"))


    .assertHasClickAction()


    useUnmergedTree = false
    Window
    Button
    Text
    Image
    MERGED NODE TREE


    UNMERGED NODE TREE


    View full-size slide

  25. Window
    Button
    Window
    Button
    Text
    Image
    onNode(hasText("Schedule a task"))


    .assertHasClickAction()


    MERGED NODE TREE


    UNMERGED NODE TREE


    View full-size slide

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


    View full-size slide

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


    View full-size slide

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

    View full-size slide

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

    View full-size slide

  30. FINDER


    onAllNodesWithText(“+")[1]


    .performClick()


    .performClick()


    onAllNodes(hasText(“+"))[0]


    .performClick()


    onNode(hasText("Pay (€ 18.5)"))


    .assertIsDisplayed()


    View full-size slide

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


    View full-size slide

  32. 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) {}


    View full-size slide

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


    View full-size slide

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


    View full-size slide

  35. “Plus” button

    View full-size slide

  36. “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()


    }


    }


    }


    View full-size slide

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


    )


    }


    View full-size slide

  38. LAST RESORT


    Modifier.testTag(...)


    View full-size slide

  39. ACCESS TO STRING RESOURCES


    class SwitchTest {


    @get:Rule


    val composeTestRule = createAndroidComposeRule()


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


    )


    }


    View full-size slide

  40. WAIT FOR RESULT


    composableTestRule.apply {


    // IdlingResources


    registerIdlingResource(...)


    unregisterIdlingResource(...)


    // waitUntil


    waitUntil {


    onAllNodesWithText("Emotions")


    .fetchSemanticsNodes().size == 1


    }


    onNode(hasContentDescription("Add"))


    .performClick()


    }

    View full-size slide

  41. ENABLE/DISABLE
    SYNCHRONIZATION
    ADVANCE TIME BY A
    SPECIFIC DURATION
    IDLING RESOURCES
    SUPPORT

    View full-size slide

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


    ...


    }


    }


    }

    View full-size slide

  43. SCREENSHOT TESTS

    View full-size slide

  44. SHOT


    SNAPPY


    DROPSHOTS


    ROBORAZZI


    ANDROID-TESTIFY PAPARAZZI SCREENSHOT-
    TESTS-FOR-
    ANDROID

    View full-size slide

  45. SHOT


    SNAPPY


    DROPSHOTS


    ROBORAZZI


    ANDROID-TESTIFY
    PAPARAZZI
    SCREENSHOT-
    TESTS-FOR-
    ANDROID
    ON DEVICE
    JVM JVM & ON DEVICE

    View full-size slide

  46. PAPARAZZI
    SNAPPY


    ROBORAZZI


    ROBOLECTRIC LAYOUTLIB

    View full-size slide

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


    )


    }


    }


    }


    View full-size slide

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

    View full-size slide

  49. SHOT PAPARAZZI

    View full-size slide

  50. @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"


    )


    }


    }

    View full-size slide

  51. @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"


    }


    }


    }


    View full-size slide

  52. EFFICIENT UI TESTING

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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


    View full-size slide

  56. END-TO-END TESTS
    FUNCTIONAL TESTS
    SCREENSHOT TESTS

    View full-size slide

  57. Explore More


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

    View full-size slide