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 Slide

  2. View Slide

  3. ANDROID TESTS
    LOCAL INSTRUMENTATION

    View Slide

  4. ANDROID TESTS
    LOCAL INSTRUMENTATION
    UI* Non-UI

    View Slide

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

    View Slide

  6. Good Test Case

    View Slide

  7. STABLE FAST READABLE INDEPENDENT

    View Slide

  8. Interaction Pixel perfectness

    View Slide

  9. INTERACTION
    PIXEL PERFECTNESS

    View Slide

  10. INTERACTION
    PIXEL PERFECTNESS

    View Slide

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

  12. SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

    View Slide

  13. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View Slide

  14. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View Slide

  15. FUNCTIONAL TESTS
    END TO END TESTS
    SCREENSHOT TESTS

    View Slide

  16. END-TO-END TESTS
    FUNCTIONAL TESTS
    SCREENSHOT TESTS

    View Slide

  17. State of Android


    Dev & Testing

    View Slide

  18. ACTIVITY FRAGMENT

    View Slide

  19. ACTIVITY FRAGMENT
    COMPOSABLE
    FUNCTION

    View Slide

  20. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    ActivityScenario.launch(


    MainActivity::class.java


    )


    View Slide

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

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

  23. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    Activity
    Fragment

    View Slide

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

    View Slide

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

  26. launchActivity
    launchFragmentInContainer
    ComposeContentTestRule
    Component
    Activity
    @Composable

    View Slide

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

    View Slide

  28. NO ID

    OF COMPONENT
    SEMANTIC
    PROPERTIES
    VIRTUAL CLOCK

    View Slide

  29. Testing Composable code

    View Slide

  30. CREATE COMPOSE
    RULE
    CREATE EMPTY
    COMPOSE RULE
    CREATE ANDROID
    COMPOSE RULE

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


    }


    }


    }


    View 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

    View 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

    View Slide

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

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

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

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

  38. onAllNodesWithText(“+")[1]


    .performClick()


    .performClick()


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


    .performClick()


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


    .assertIsDisplayed()


    View Slide

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

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


    View Slide

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

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

  44. View Slide

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

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

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

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

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

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

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

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

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

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

    View Slide

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

  56. Testing hybrid apps

    View Slide

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

    View Slide

  58. Best Practices

    View Slide

  59. USE MULTIPLE SMALL TESTS
    INSTEAD OF ONE BIG TEST

    View Slide

  60. ALL TEST CASES SHOULD BE
    INDEPENDENT

    View Slide

  61. NO "SLEEP" IN TESTS

    View Slide

  62. LEARN YOUR TOOLS

    View Slide

  63. #ExploreMore


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

    View Slide