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.

2b0404a5db1a74f01bf3bf94d142e28c?s=128

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

  2. None
  3. ANDROID TESTS LOCAL INSTRUMENTATION

  4. ANDROID TESTS LOCAL INSTRUMENTATION UI* Non-UI

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

  6. Good Test Case

  7. STABLE FAST READABLE INDEPENDENT

  8. Interaction Pixel perfectness

  9. INTERACTION PIXEL PERFECTNESS

  10. INTERACTION PIXEL PERFECTNESS

  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
  12. SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

  13. FUNCTIONAL TESTS END TO END TESTS SCREENSHOT TESTS

  14. FUNCTIONAL TESTS END TO END TESTS SCREENSHOT TESTS

  15. FUNCTIONAL TESTS END TO END TESTS SCREENSHOT TESTS

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

  17. State of Android Dev & Testing

  18. ACTIVITY FRAGMENT

  19. ACTIVITY FRAGMENT COMPOSABLE FUNCTION

  20. launchActivity launchFragmentInContainer ComposeContentTestRule ActivityScenario.launch( MainActivity::class.java )

  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(
  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"
  23. launchActivity launchFragmentInContainer ComposeContentTestRule Activity Fragment

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

  25. launchActivity launchFragmentInContainer ComposeContentTestRule @get:Rule val composeTestRule = createComposeRule() composeTestRule.apply {

    setContent { LoginScreen() } onNodeWithTag("email") .performTextInput("test@test.com") onNodeWithTag("password") .performTextInput("password") onNodeWithTag("login") .performClick() ... } debugImplementation "androidx.compose.ui:ui-test-manifest:1:0:5"
  26. launchActivity launchFragmentInContainer ComposeContentTestRule Component Activity @Composable

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

    AndroidManifest
  28. NO ID 
 OF COMPONENT SEMANTIC PROPERTIES VIRTUAL CLOCK

  29. Testing Composable code

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

    RULE
  31. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("test@test.com") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } }
  32. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("test@test.com") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher
  33. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("test@test.com") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher Action
  34. class SubscribeBoxTest { @get:Rule val composeTestRule = createComposeRule() @Test fun

    shouldDisplaySnackbar_whenEmailIsEnteredAndSubscribeClicked() { composeTestRule.apply { setContent { SubscribeBox() } onNode(hasTestTag("subscribe_email")) .performTextInput("test@test.com") onNode(hasText("SUBSCRIBE")) .performClick() onNode(hasText("You successfully subscribed")) .assertIsDisplayed() } } } Finder Matcher Action Assertion
  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]
  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")
  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")
  38. onAllNodesWithText(“+")[1] .performClick() .performClick() onAllNodes(hasText(“+"))[0] .performClick() onNode(hasText("Pay (€ 18.5)")) .assertIsDisplayed()

  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]
  40. Node #1 at (l=0.0, t=74.0, r=1080.0, b=1188.0)px

  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) {}
  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))
  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))
  44. None
  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() ) } } } }
  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() ) } } } }
  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)) ) }
  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)) ) }
  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) } } }
  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
  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
  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) }
  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) }
  54. ENABLE/DISABLE SYNCHRONIZATION ADVANCE TIME BY A SPECIFIC DURATION IDLING RESOURCES

    SUPPORT
  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") } } }
  56. Testing hybrid apps

  57. COMBINE MULTIPLE FRAMEWORKS IN A TEST COMBINE DIFFERENT APPROACHES USE

    DSL (DOMAIN- SPECIFIC-LANGUAGE)
  58. Best Practices

  59. USE MULTIPLE SMALL TESTS INSTEAD OF ONE BIG TEST

  60. ALL TEST CASES SHOULD BE INDEPENDENT

  61. NO "SLEEP" IN TESTS

  62. LEARN YOUR TOOLS

  63. #ExploreMore UI Testing @alex_zhukovich https://alexzh.com/