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

UI testing of Jetpack Compose apps - Dutch Android User Group

UI testing of Jetpack Compose apps - Dutch Android User Group

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 the UI of Android applications, how efficiently verify apps which includes both approaches to building UI.

Alex Zhukovich

April 07, 2022
Tweet

More Decks by Alex Zhukovich

Other Decks in Technology

Transcript

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

    Jetpack Compose apps

    View full-size slide

  2. ANDROID TESTS
    LOCAL INSTRUMENTATION

    View full-size slide

  3. ANDROID TESTS
    LOCAL INSTRUMENTATION
    UI* Non-UI

    View full-size slide

  4. ANDROID TESTS
    LOCAL INSTRUMENTATION
    UI* Non-UI UI Non-UI

    View full-size slide

  5. Good Test Case

    View full-size slide

  6. STABLE FAST READABLE INDEPENDENT

    View full-size slide

  7. Interaction Pixel perfectness

    View full-size slide

  8. INTERACTION
    PIXEL PERFECTNESS

    View full-size slide

  9. INTERACTION
    PIXEL PERFECTNESS

    View full-size slide

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

  11. SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

    View full-size slide

  12. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View full-size slide

  13. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View full-size slide

  14. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View full-size slide

  15. END-TO-END TESTS
    FUNCTIONAL TESTS
    SCREENSHOT TESTS

    View full-size slide

  16. State of Android


    Dev & Testing

    View full-size slide

  17. ACTIVITY FRAGMENT

    View full-size slide

  18. ACTIVITY FRAGMENT
    COMPOSABLE
    FUNCTION

    View full-size slide

  19. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    ActivityScenario.launch(


    MainActivity::class.java


    )


    View full-size slide

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

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

    View full-size slide

  22. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    Activity
    Fragment

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  25. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    Component
    Activity
    @Composable

    View full-size slide

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

    View full-size slide

  27. NO ID

    OF COMPONENT
    SEMANTIC
    PROPERTIES
    VIRTUAL CLOCK

    View full-size slide

  28. Testing Composable code

    View full-size slide

  29. CREATE COMPOSE
    RULE
    CREATE EMPTY
    COMPOSE RULE
    CREATE ANDROID
    COMPOSE RULE

    View full-size 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()


    }


    }


    }


    View full-size slide

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

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

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

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

  35. 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'
    onRoot(useUnmergedTree = false)

    .printToLog("MERGED")


    onRoot(useUnmergedTree = true)


    .printToLog("UNMERGED")


    View full-size slide

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

  37. onAllNodesWithText(“+")[1]


    .performClick()


    .performClick()


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


    .performClick()


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


    .assertIsDisplayed()


    View full-size slide

  38. Node #1 at (l=0.0, t=74.0, r=1080.0, b=1188.0)px


    |-Node #2 at (l=0.0, t=74.0, r=1080.0, b=1016.0)px


    | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)'


    | CollectionInfo = 'androidx.compose.ui.semantics.CollectionInfo@75c2267'


    | Actions = [IndexForKey, ScrollBy, ScrollToIndex]


    | |-Node #4 at (l=271.0, t=99.0, r=907.0, b=173.0)px


    | | Text = '[Americano]'


    | | Actions = [GetTextLayoutResult]


    | |-Node #5 at (l=271.0, t=185.0, r=907.0, b=345.0)px


    | | Text = '[Americano is ...]'


    | | Actions = [GetTextLayoutResult]


    | |-Node #6 at (l=932.0, t=99.0, r=1055.0, b=360.0)px


    | | |-Node #7 at (l=932.0, t=99.0, r=1055.0, b=186.0)px


    | | | Focused = 'false'


    | | | Text = '[+]'


    | | | Actions = [OnClick, GetTextLayoutResult]


    | | | MergeDescendants = 'true'


    | | |-Node #10 at (l=976.0, t=186.0, r=1011.0, b=273.0)px


    | | | Text = '[0]'


    | | | Actions = [GetTextLayoutResult]


    | | |-Node #11 at (l=932.0, t=273.0, r=1055.0, b=360.0)px


    | | Focused = 'false'


    | | Text = '[—]'


    | | Actions = [OnClick, GetTextLayoutResult]


    | | MergeDescendants = 'true'


    | |-Node #14 at (l=271.0, t=413.0, r=907.0, b=487.0)px


    | | Text = '[Cappuccino]'


    | | Actions = [GetTextLayoutResult]


    | |-Node #15 at (l=271.0, t=499.0, r=907.0, b=659.0)px


    | | Text = '[A cappuccino is ...]’


    | | Actions = [GetTextLayoutResult]


    | |-Node #16 at (l=932.0, t=413.0, r=1055.0, b=674.0)px


    | | |-Node #17 ...


    | | |-Node #20 ...


    | | |-Node #21 ...


    | |-Node #24 at (l=271.0, t=727.0, r=907.0, b=801.0)px


    | | Text = '[Espresso]'


    | | Actions = [GetTextLayoutResult]


    | |-Node #25 at (l=271.0, t=813.0, r=907.0, b=973.0)px


    | | Text = '[Espresso is ...]’


    | | Actions = [GetTextLayoutResult]


    | |-Node #26 at (l=932.0, t=727.0, r=1055.0, b=988.0)px


    | |-Node #27 ...


    | |-Node #30 ...


    | |-Node #31 ...


    |-Node #3 at (l=49.0, t=1065.0, r=1031.0, b=1139.0)px


    Text = '[Pay (€ 0)]'


    Actions = [GetTextLayoutResult]

    View full-size slide

  39. Node #1 at (l=0.0, t=74.0, r=1080.0, b=1188.0)px


    View full-size slide

  40. Node #1 at (l=0.0, t=74.0, r=1080.0, b=1188.0)px


    |-Node #2 at (l=0.0, t=74.0, r=1080.0, b=1016.0)px


    | VerticalScrollAxisRange = 'ScrollAxisRange(value=0.0, maxValue=0.0, reverseScrolling=false)'


    | CollectionInfo = 'androidx.compose.ui.semantics.CollectionInfo@75c2267'


    | Actions = [IndexForKey, ScrollBy, ScrollToIndex]


    | |-Node #4 at (l=25.0, t=99.0, r=1055.0, b=360.0)px


    | | |-Node #5 at (l=271.0, t=99.0, r=907.0, b=173.0)px


    | | | Text = '[Americano]'


    | | | Actions = [GetTextLayoutResult]


    | | |-Node #6 at (l=271.0, t=185.0, r=907.0, b=345.0)px


    | | | Text = '[Americano is ...]’


    | | | Actions = [GetTextLayoutResult]


    | | |-Node #7 at (l=932.0, t=99.0, r=1055.0, b=360.0)px


    | | |-Node #8 at (l=932.0, t=99.0, r=1055.0, b=186.0)px


    | | | Focused = 'false'


    | | | Text = '[+]'


    | | | Actions = [OnClick, GetTextLayoutResult]


    | | | MergeDescendants = 'true'


    | | |-Node #11 at (l=976.0, t=186.0, r=1011.0, b=273.0)px


    | | | Text = '[0]'


    | | | Actions = [GetTextLayoutResult]


    | | |-Node #12 at (l=932.0, t=273.0, r=1055.0, b=360.0)px


    | | Focused = 'false'


    | | Text = '[—]'


    | | Actions = [OnClick, GetTextLayoutResult]


    | | MergeDescendants = 'true'


    | |-Node #15 at (l=25.0, t=413.0, r=1055.0, b=674.0)px


    | | |-Node #16 at (l=271.0, t=413.0, r=907.0, b=487.0)px


    | | | Text = '[Cappuccino]'


    | | | Actions = [GetTextLayoutResult]


    | | |-Node #17 at (l=271.0, t=499.0, r=907.0, b=659.0)px


    | | | Text = '[A cappuccino is ...]’


    | | | Actions = [GetTextLayoutResult]


    | | |-Node #18 at (l=932.0, t=413.0, r=1055.0, b=674.0)px


    | | |-Node #19 ...


    | | |-Node #22 ...


    | | |-Node #23 ...


    | |-Node #26 at (l=25.0, t=727.0, r=1055.0, b=988.0)px


    | |-Node #27 at (l=271.0, t=727.0, r=907.0, b=801.0)px


    | | Text = '[Espresso]'


    | | Actions = [GetTextLayoutResult]


    | |-Node #28 at (l=271.0, t=813.0, r=907.0, b=973.0)px


    | | Text = '[Espresso is ...]’


    | | Actions = [GetTextLayoutResult]


    | |-Node #29 at (l=932.0, t=727.0, r=1055.0, b=988.0)px


    | |-Node #30 ...


    | |-Node #33 ...


    | |-Node #34 ...


    |-Node #3 at (l=49.0, t=1065.0, r=1031.0, b=1139.0)px


    Text = '[Pay (€ 0)]'


    Actions = [GetTextLayoutResult]
    Modifier.semantics(mergeDescendants = false) {}


    View full-size slide

  41. Modifier.semantics(mergeDescendants = false) {}


    class CoffeeDrinksTest {


    @get:Rule


    val composeTestRule = createComposeRule()


    @Test


    fun coffeeDrinksAreNotAddedToBasket_whenCoffeeAddedToBasket_thenPaymentAmountRecalculated() {


    composeTestRule.apply {


    setContent { CoffeeDrinksWithBasket(drinks = coffeeDrinks) }


    onNode(withIncrementForCoffeeDrink("Espresso"))


    .performClick()


    onNode(withIncrementForCoffeeDrink("Americano"))


    .performClick()


    onNodeWithIncrementForCoffeeDrink("Americano")


    .performClick()


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


    .assertIsDisplayed()


    }


    }


    }


    fun withIncrementForCoffeeDrink(text: String): SemanticsMatcher {


    return hasText("+")


    .and(hasAnyAncestor(hasAnyChild(hasText(text))))


    }


    fun SemanticsNodeInteractionsProvider.onNodeWithIncrementForCoffeeDrink(text: String) =


    onNode(withIncrementForCoffeeDrink(text))

    View full-size slide

  42. Modifier.semantics(mergeDescendants = false) {}


    class CoffeeDrinksTest {


    @get:Rule


    val composeTestRule = createComposeRule()


    @Test


    fun coffeeDrinksAreNotAddedToBasket_whenCoffeeAddedToBasket_thenPaymentAmountRecalculated() {


    composeTestRule.apply {


    setContent { CoffeeDrinksWithBasket(drinks = coffeeDrinks) }


    onNode(withIncrementForCoffeeDrink("Espresso"))


    .performClick()


    onNode(withIncrementForCoffeeDrink("Americano"))


    .performClick()


    onNodeWithIncrementForCoffeeDrink("Americano")


    .performClick()


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


    .assertIsDisplayed()


    }


    }


    }


    fun withIncrementForCoffeeDrink(text: String): SemanticsMatcher {


    return hasText("+")


    .and(hasAnyAncestor(hasAnyChild(hasText(text))))


    }


    fun SemanticsNodeInteractionsProvider.onNodeWithIncrementForCoffeeDrink(text: String) =


    onNode(withIncrementForCoffeeDrink(text))

    View full-size slide

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

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

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

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

  47. COLUMN
    LAZY COLUMN
    val amount = 1_000


    LazyColumn(


    modifier = Modifier.testTag("list")


    ) {


    items(amount) {


    Row(


    modifier = Modifier.padding(8.dp)


    ) {


    Text(text = "Item $it", fontSize = 20.sp)


    }


    }


    }


    val amount = 1_000


    Column(


    modifier = Modifier.testTag("list")


    ) {


    (1..amount).forEach {


    Row(


    modifier = Modifier.padding(8.dp)


    ) {


    Text(text = "Item $it", fontSize = 20.sp)


    }


    }


    }


    View full-size slide

  48. COLUMN
    ADD UI
    val amount = 1_000


    Column(


    modifier = Modifier.testTag("list")


    ) {


    (0..amount).forEach {


    Row(


    modifier = Modifier.padding(8.dp)


    ) {


    Text(text = "Item $it", fontSize = 20.sp)


    }


    }


    }


    onNode(hasText("Item 14"))


    .assertIsDisplayed()


    onNode(hasTestTag("list"))


    .performScrollToIndex(80)


    onNode(hasText("Item 80"))


    .assertIsDisplayed()


    Scroll actions:


    performScrollToNode(hasText("Item 80”))


    performScrollToIndex(80)
    15 ITEMS ARE DISPLAYED
    ALL ITEMS ARE EXIST

    View full-size slide

  49. LAZY COLUMN
    ADD UI
    val amount = 1_000


    LazyColumn(


    modifier = Modifier.testTag("list")


    ) {


    items(amount) {


    Row(


    modifier = Modifier.padding(8.dp)


    ) {


    Text(text = "Item $it", fontSize = 20.sp)


    }


    }


    }


    onNode(hasText("Item 14"))


    .assertIsDisplayed()


    onNode(hasTestTag("list"))


    .performScrollToIndex(80)


    onNode(hasText("Item 80"))


    .assertIsDisplayed()


    Scroll actions:


    performScrollToNode(hasText("Item 80”))


    performScrollToIndex(80)
    15 ITEMS ARE DISPLAYED
    15 ITEMS ARE EXIST

    View full-size slide

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

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

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

    View full-size slide

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

  54. Testing hybrid apps

    View full-size slide

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

    View full-size slide

  56. Best Practices

    View full-size slide

  57. USE MULTIPLE SMALL TESTS
    INSTEAD OF ONE BIG TEST

    View full-size slide

  58. ALL TEST CASES SHOULD BE
    INDEPENDENT

    View full-size slide

  59. NO "SLEEP" IN TESTS

    View full-size slide

  60. LEARN YOUR TOOLS

    View full-size slide

  61. #ExploreMore


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

    View full-size slide