Slide 1

Slide 1 text

@alex_zhukovich https://alexzh.com/ UI TESTING JETPACK COMPOSE

Slide 2

Slide 2 text

No content

Slide 3

Slide 3 text

INTERACTION

Slide 4

Slide 4 text

PIXEL PERFECTNESS INTERACTION

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

SCREENSHOT TESTS FUNCTIONAL TESTS END TO END TESTS

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

ACTIVITY

Slide 11

Slide 11 text

ACTIVITY FRAGMENT

Slide 12

Slide 12 text

ACTIVITY FRAGMENT COMPOSABLE FUNCTION

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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 )

Slide 15

Slide 15 text

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)

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

NO ID 
 OF COMPONENT SEMANTIC PROPERTIES VIRTUAL CLOCK

Slide 18

Slide 18 text

INTERACTION TESTS

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

COMPOSE TEST RULE ANDROID COMPOSE TEST RULE EMPTY COMPOSE TEST RULE

Slide 24

Slide 24 text

MERGED NODE TREE UNMERGED NODE TREE

Slide 25

Slide 25 text

Window Button MERGED NODE TREE UNMERGED NODE TREE

Slide 26

Slide 26 text

Window Button MERGED NODE TREE UNMERGED NODE TREE Window Button Text Image

Slide 27

Slide 27 text

Window Button onNode(hasText("Schedule a task")) .assertHasClickAction() useUnmergedTree = false Window Button Text Image MERGED NODE TREE UNMERGED NODE TREE

Slide 28

Slide 28 text

Window Button Window Button Text Image onNode(hasText("Schedule a task")) .assertHasClickAction() MERGED NODE TREE UNMERGED NODE TREE

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

FINDER onAllNodesWithText(“+")[1] .performClick() .performClick() onAllNodes(hasText(“+"))[0] .performClick() onNode(hasText("Pay (€ 18.5)")) .assertIsDisplayed()

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

“Plus” button

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

LAST RESORT Modifier.testTag(...)

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

WAIT FOR RESULT composableTestRule.apply { // IdlingResources registerIdlingResource(...) unregisterIdlingResource(...) // waitUntil waitUntil { onAllNodesWithText("Emotions") .fetchSemanticsNodes().size == 1 } onNode(hasContentDescription("Add")) .performClick() }

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

SCREENSHOT TESTS

Slide 47

Slide 47 text

No content

Slide 48

Slide 48 text

No content

Slide 49

Slide 49 text

SHOT SNAPPY DROPSHOTS ROBORAZZI ANDROID-TESTIFY PAPARAZZI SCREENSHOT- TESTS-FOR- ANDROID

Slide 50

Slide 50 text

SHOT SNAPPY DROPSHOTS ROBORAZZI ANDROID-TESTIFY PAPARAZZI SCREENSHOT- TESTS-FOR- ANDROID ON DEVICE JVM JVM & ON DEVICE

Slide 51

Slide 51 text

PAPARAZZI SNAPPY ROBORAZZI ROBOLECTRIC LAYOUTLIB

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

SHOT PAPARAZZI

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

@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" } } }

Slide 57

Slide 57 text

No content

Slide 58

Slide 58 text

EFFICIENT UI TESTING

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

END-TO-END TESTS FUNCTIONAL TESTS SCREENSHOT TESTS

Slide 63

Slide 63 text

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