Slide 1

Slide 1 text

stewemetal How to Test Your Compose UI István Juhos

Slide 2

Slide 2 text

#DroidKaigi2022 – @stewemetal Source of examples github.com/stewemetal/composehydrationtracker

Slide 3

Slide 3 text

#DroidKaigi2022 – @stewemetal Testing Views - Recap ☕ • View(Group) objects with rendering and behavior • Views are inflated from XML descriptors or instantiated in code • Views are directly referrable by View IDs

Slide 4

Slide 4 text

#DroidKaigi2022 – @stewemetal Testing Compose UI 🧪

Slide 5

Slide 5 text

#DroidKaigi2022 – @stewemetal Testing Compose UI • No direct access to the Composition • No UI objects to look up directly • No UI element IDs • Not every Composable emits UI ❌ ❌ ❌ ❌ 🧪

Slide 6

Slide 6 text

#DroidKaigi2022 – @stewemetal @Composable fun EntryList( entries: List = emptyList(), ) { LazyColumn { items(entries) { item -> HydrationItem(item = item) } } } @Composable fun HydrationItem(item: HydrationEntry) { Row( modifier = Modifier .wrapContentHeight( align = Alignment.CenterVertically, Composables to test: EntryList #DroidKaigi2022 – @stewemetal EntryList HydrationItem

Slide 7

Slide 7 text

#DroidKaigi2022 – @stewemetal HydrationItem(item = item) } } } @Composable fun HydrationItem(item: HydrationEntry) { Row( modifier = Modifier .wrapContentHeight( align = Alignment.CenterVertically, ) .padding(16.dp) ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text("${item.dateTime}") } } Composables to test: HydrationItem HydrationItem EntryList #DroidKaigi2022 – @stewemetal

Slide 8

Slide 8 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests

Slide 9

Slide 9 text

#DroidKaigi2022 – @stewemetal // Test rules and transitive dependencies: androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version” // Needed for createComposeRule, but not createAndroidComposeRule: debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" Setting up Compose UI tests

Slide 10

Slide 10 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4!::class) class EntryListTest { !// !!... } create ComposeRule () Android @get:Rule val composeTestRule =

Slide 11

Slide 11 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class EntryListTest { // ... } create ComposeRule () Android @get:Rule val composeTestRule = https://www.droidcon.com/2022/08/01/modern-testing-on-android/ Modern Testing on Android

Slide 12

Slide 12 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class EntryListTest { // ... } create ComposeRule () Android @get:Rule val composeTestRule =

Slide 13

Slide 13 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4!::class) class EntryListTest { !// !!... } create ComposeRule () Android @get:Rule val composeTestRule =

Slide 14

Slide 14 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class EntryListTest { // ... } create ComposeRule () Android @get:Rule val composeTestRule =

Slide 15

Slide 15 text

#DroidKaigi2022 – @stewemetal Setting up Compose UI tests @RunWith(AndroidJUnit4::class) class EntryListTest { // ... } createComposeRule() @get:Rule val composeTestRule =

Slide 16

Slide 16 text

#DroidKaigi2022 – @stewemetal @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } } A simple UI test createComposeRule() @get:Rule val composeTestRule =

Slide 17

Slide 17 text

#DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } }

Slide 18

Slide 18 text

#DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } }

Slide 19

Slide 19 text

#DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } "// ""... } }

Slide 20

Slide 20 text

#DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } "// ""... } }

Slide 21

Slide 21 text

#DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } } private val entries = listOf( HydrationEntry(…), HydrationEntry(…), HydrationEntry(…), )

Slide 22

Slide 22 text

#DroidKaigi2022 – @stewemetal A simple UI test @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } "// ""... } } So, how can we act or assert on EntryList? 🤔

Slide 23

Slide 23 text

#DroidKaigi2022 – @stewemetal The semantics tree 🌳

Slide 24

Slide 24 text

#DroidKaigi2022 – @stewemetal The semantics tree 🌳 • A parallel tree next to the Composition 🌲🌳 • Used for accessibility and testing • Composables (can) contribute to the semantics tree

Slide 25

Slide 25 text

#DroidKaigi2022 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent { Text("100 ml") } } Semantics tree root node Semantic node with text "100 ml"

Slide 26

Slide 26 text

#DroidKaigi2022 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent { Text("100 ml") } } Root Text = '[100 ml]'

Slide 27

Slide 27 text

#DroidKaigi2022 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent { Row { Text("100 ml") } } } Root Text = '[100 ml]'

Slide 28

Slide 28 text

#DroidKaigi2022 – @stewemetal The semantics tree 🌳 composeTestRule.apply { setContent { Row { Text("100 ml") Text("2022-10-05") } } } Text = '[100 ml]' Text = '[2022-10-05]' Root

Slide 29

Slide 29 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 Text = '[100 ml]' Text = '[2022-10-05]' Root Row { Text("100 ml") Text("2022-10-05") }

Slide 30

Slide 30 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 • In Compose tests onRoot() .printToLog("RowAndTexts") Text = '[100 ml]' Text = '[2022-10-05]' Root

Slide 31

Slide 31 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 • In Compose tests onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root

Slide 32

Slide 32 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 • In Compose tests onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root

Slide 33

Slide 33 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 • In Compose tests onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root

Slide 34

Slide 34 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 • In Compose tests onRoot() .printToLog("RowAndTexts") D/RowAndTexts: printToLog: Printing with useUnmergedTree = 'false' Node #1 at (l=0.0, t=66.0, r=224.0, b=125.0)px |-Node #2 at (l=0.0, t=66.0, r=136.0, b=125.0)px | Text = '[100 ml]' | Actions = [GetTextLayoutResult] |-Node #3 at (l=0.0, t=66.0, r=224.0, b=125.0)px Text = '[2022-10-05]' Actions = [GetTextLayoutResult] Text = '[100 ml]' Text = '[2022-10-05]' Root

Slide 35

Slide 35 text

#DroidKaigi2022 – @stewemetal Visualizing the semantics tree 🌳👀 • In Android Studio • Layout Inspector

Slide 36

Slide 36 text

#DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { // ... } ) { Text("100 ml") Text("2022-10-05") }

Slide 37

Slide 37 text

#DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { // ... } ) { Text("100 ml") Text("2022-10-05") }

Slide 38

Slide 38 text

#DroidKaigi2022 – @stewemetal Root Text = '[100 ml]' Text = '[2022-10-05]' Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { // ... } ) { Text("100 ml") Text("2022-10-05") }

Slide 39

Slide 39 text

#DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { contentDescription = "List item" } ) { Text("100 ml") Text("2022-10-05") } Root Text = '[100 ml]' Text = '[2022-10-05]' ContentDescription = '[List item]'

Slide 40

Slide 40 text

#DroidKaigi2022 – @stewemetal Modifying the semantics tree 🌳 Row( modifier = Modifier .semantics { testTag = "List item" } ) { Text("100 ml") Text("2022-10-05") } Root Text = '[100 ml]' Text = '[2022-10-05]' Tag = '[List item]'

Slide 41

Slide 41 text

#DroidKaigi2022 – @stewemetal Merging semantics - unmerged Row( modifier = Modifier .wrapContentHeight( align ) .padding(16.dp) ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } Text = '[2022-10-05]' Text = '[100 ml]' Root = Alignment.CenterVertically,

Slide 42

Slide 42 text

#DroidKaigi2022 – @stewemetal Merging semantics - unmerged Row( modifier = Modifier .wrapContentHeight( align ) .padding(16.dp) .semantics(mergeDescendants = false) {} ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } = Text = '[2022-10-05]' Text = '[100 ml]' Root Alignment.CenterVertically,

Slide 43

Slide 43 text

#DroidKaigi2022 – @stewemetal Merging semantics - merged Row( modifier = Modifier .wrapContentHeight( align ) .padding(16.dp) .semantics(mergeDescendants = true) {} ) { Text( "${item.milliliters} ml", modifier = Modifier.weight(1f), ) Text( "${item.dateTime}", ) } = Text = '[100 ml, 2022-10-05]' Root Alignment.CenterVertically,

Slide 44

Slide 44 text

#DroidKaigi2022 – @stewemetal Merged semantics by default Role = 'Button' Focused = 'false' Text = '[100 ml]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' Button( onClick = {}, ) { Text("100 ml") }

Slide 45

Slide 45 text

#DroidKaigi2022 – @stewemetal Clear semantics Text = '[Careful with this 😉]' ClearAndSetSemantics = 'true' Button( onClick = {}, modifier = Modifier .clearAndSetSemantics { text = AnnotatedString("Careful with this 😉") }, ) { Text("100 ml") }

Slide 46

Slide 46 text

#DroidKaigi2022 – @stewemetal Clear semantics Text = '[Careful with this 😉]' ClearAndSetSemantics = 'true' Button( onClick = {}, modifier = Modifier .clearAndSetSemantics { text = AnnotatedString("Careful with this 😉") }, ) { Text("100 ml") }

Slide 47

Slide 47 text

#DroidKaigi2022 – @stewemetal Using the semantics tree... @get:Rule val composeTestRule = createComposeRule() @Test fun entriesExist_entriesDisplayed() { composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } // ... } } !!...we can test Compose UI 🤩

Slide 48

Slide 48 text

#DroidKaigi2022 – @stewemetal Compose testing API

Slide 49

Slide 49 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onRoot onRoot() .printToLog(”TAG") Root Text = '[100 ml, 2022-10-05]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-10-05]'

Slide 50

Slide 50 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onNodeWithTag onNodeWithTag("List item") Root Text = '[100 ml, 2022-10-05]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-10-05]'

Slide 51

Slide 51 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onNodeWithContentDescription onNodeWithContentDescription( "100 ml on 2022-10-05", ) Root Text = '[100 ml, 2022-10-05]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-10-05]'

Slide 52

Slide 52 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onNodeWithText onNodeWithText( "100 ml", ) Root Text = '[100 ml, 2022-10-05]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-10-05]'

Slide 53

Slide 53 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onNodeWithText onNodeWithText( "100 ml", useUnmergedTree = false, ) Root Text = '[100 ml, 2022-10-05]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-10-05]'

Slide 54

Slide 54 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onNodeWithText Root Text = '[100 ml]' Text = '[2022-10-05]' Tag = '[List item]' ContentDescription = '[100 ml on 2022-10-05]' onNodeWithText( "100 ml", useUnmergedTree = true, )

Slide 55

Slide 55 text

#DroidKaigi2022 – @stewemetal Select nodes - Finders • onAllNodesWith... Tag = '[entries_list]' onAllNodesNodeWithTag("List item") Tag = '[List item]' Text = '[100 ml, 2022-10-05]' Tag = '[List item]' Text = '[500 ml, 2022-07-04]' !!...

Slide 56

Slide 56 text

#DroidKaigi2022 – @stewemetal Matchers •onNode() • hasText • hasContentDescription • hasTestTag • isEnabled • isSelected • isRoot • ...

Slide 57

Slide 57 text

#DroidKaigi2022 – @stewemetal Actions • performClick • performScrollTo... • performTextInput • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performClick()

Slide 58

Slide 58 text

#DroidKaigi2022 – @stewemetal Actions • performClick • performScrollTo... • performTextInput • performGesture • performKeyPress • ... Role = 'Button' Focused = 'false' Text = '[100 ml]' Actions = [OnClick, GetTextLayoutResult] MergeDescendants = 'true' onNodeWithText("100 ml") .performClick()

Slide 59

Slide 59 text

#DroidKaigi2022 – @stewemetal Actions • performClick • performScrollTo... • performTextInput • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performTextInput( "..." )

Slide 60

Slide 60 text

#DroidKaigi2022 – @stewemetal Actions • performClick • performScrollTo... • performTextInput • performGesture • performKeyPress • ... onNodeWithText("100 ml") .performTextInput( "..." )

Slide 61

Slide 61 text

#DroidKaigi2022 – @stewemetal Compose testing cheatseet developer.android.com/jetpack/compose/testing-cheatsheet

Slide 62

Slide 62 text

#DroidKaigi2022 – @stewemetal Testing in isolation

Slide 63

Slide 63 text

#DroidKaigi2022 – @stewemetal Testing in isolation • Structure your composables with testing in mind @Composable fun EntryList( entries: List = emptyList() ) { LazyColumn( modifier = Modifier.testTag("entries_list") ) { items(entries) { item -> HydrationItem(item = item) } } }

Slide 64

Slide 64 text

#DroidKaigi2022 – @stewemetal Testing in isolation • Structure your composables with testing in mind @Composable fun EntriesScreen( viewModel: EntriesViewModel = hiltViewModel() ) { when (val uiState = viewModel.uiState) { Loading -> EntriesLoading() is Content -> EntryList(entries = uiState.entries) } }

Slide 65

Slide 65 text

#DroidKaigi2022 – @stewemetal Testing in isolation • Structure your composables with testing in mind @Composable fun EntriesScreen( viewModel: EntriesViewModel = hiltViewModel() ) { when (val uiState = viewModel.uiState) { Loading -> EntriesLoading() is Content -> EntryList(entries = uiState.entries) } }

Slide 66

Slide 66 text

#DroidKaigi2022 – @stewemetal Testing in isolation • Structure your composables with testing in mind @Composable fun EntriesScreen( viewModel: EntriesViewModel = hiltViewModel() ) { when (val uiState = viewModel.uiState) { Loading -> EntriesLoading() is Content -> EntryList(entries = uiState.entries) } }

Slide 67

Slide 67 text

#DroidKaigi2022 – @stewemetal Testing in isolation composeTestRule.apply { setContent { HydrationTrackerTheme { EntryList( entries = entries ) } } onNodeWithTag("entries_list") .onChildren() .assertCountEquals(entries.size) }

Slide 68

Slide 68 text

#DroidKaigi2022 – @stewemetal Testing behavior

Slide 69

Slide 69 text

#DroidKaigi2022 – @stewemetal Testing behavior • Test behavior with fake callbacks @Composable fun PredefinedValuesInput( onAddDrink: (Int) -> Unit, ) { Row { Button(onClick = { onAddDrink(100) }) { Text("100 ml") } Button(onClick = { onAddDrink(250) }) { Text("250 ml") } Button(onClick = { onAddDrink(500) }) { Text("500 ml") } } }

Slide 70

Slide 70 text

#DroidKaigi2022 – @stewemetal Testing behavior • Test behavior with fake callbacks @Composable fun PredefinedValuesInput( onAddDrink: (Int) -> Unit, ) { Row { Button(onClick = { onAddDrink(100) }) { Text("100 ml") } Button(onClick = { onAddDrink(250) }) { Text("250 ml") } Button(onClick = { onAddDrink(500) }) { Text("500 ml") } } }

Slide 71

Slide 71 text

#DroidKaigi2022 – @stewemetal Testing behavior composeTestRule.apply { var addedDrinkValue = 0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }

Slide 72

Slide 72 text

#DroidKaigi2022 – @stewemetal Testing behavior composeTestRule.apply { var addedDrinkValue = 0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }

Slide 73

Slide 73 text

#DroidKaigi2022 – @stewemetal Testing behavior composeTestRule.apply { var addedDrinkValue = 0 setContent { HydrationTrackerTheme { PredefinedValuesInput( onAddDrink = { value -> addedDrinkValue = value }, ) } } onNodeWithText("500 ml").performClick() assertEquals(500, addedDrinkValue) }

Slide 74

Slide 74 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing

Slide 75

Slide 75 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing • Adding Compose content to existing View-based layouts • Adding existing Views to the composition • Compose & Espresso in the same tests 🪟 🪟 ☕ &

Slide 76

Slide 76 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView

Slide 77

Slide 77 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView

Slide 78

Slide 78 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView

Slide 79

Slide 79 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView

Slide 80

Slide 80 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView class ComposeViewDemoActivity : AppCompatActivity() { private lateinit var binding: ActivityComposeViewDemoBinding // ... } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityComposeViewDemoBinding.inflate(layoutInflater) setContentView(binding.root) }

Slide 81

Slide 81 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView binding.toolbar.title = "ComposeView Demo Title" binding.composeView.apply { setViewCompositionStrategy(DisposeOnDetachedFromWindow) setContent { CustomButton( text = "Demo Button", modifier = Modifier .padding(horizontal = 8.dp) .testTag("demo_custom_button"), ) {} } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityComposeViewDemoBinding.inflate(layoutInflater) setContentView(binding.root) }

Slide 82

Slide 82 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4::class) class DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } }

Slide 83

Slide 83 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4::class) class DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } }

Slide 84

Slide 84 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4::class) class DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } } ✅

Slide 85

Slide 85 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing – ComposeView @RunWith(AndroidJUnit4::class) class DemoComposeViewTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule() @Test fun hybrid_ui_customComposeButton_isVisible() { androidComposeTestRule.apply { Espresso.onView(withText("ComposeView Demo Title")) .check(matches(isDisplayed())) onNodeWithText("Demo Button").assertIsDisplayed() } } } ✅ ✅

Slide 86

Slide 86 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView

Slide 87

Slide 87 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView class CustomButtonView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : ConstraintLayout(context, attrs, defStyleAttr) { "// ""... LayoutInflater.from(context).inflate(R.layout.view_custom_button, this, true) "// ""... fun setText(text: String) { button.text = text } override fun setOnClickListener(listener: OnClickListener?) { button.setOnClickListener { listener"?.onClick(button) } } }

Slide 88

Slide 88 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView @Composable fun AndroidViewDemo( onButtonClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("AndroidView Demo Title") }) }, content = { padding -> AndroidView( modifier = Modifier.padding(padding).wrapContentHeight(), factory = { context -> CustomButtonView(context).apply { setText("Demo Button") setOnClickListener { onButtonClick() } } }, ) } ) }

Slide 89

Slide 89 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView @Composable fun AndroidViewDemo( onButtonClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("AndroidView Demo Title") }) }, content = { padding -> AndroidView( modifier = Modifier.padding(padding).wrapContentHeight(), factory = { context -> CustomButtonView(context).apply { setText("Demo Button") setOnClickListener { onButtonClick() } } }, ) } ) }

Slide 90

Slide 90 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView @Composable fun AndroidViewDemo( onButtonClick: () -> Unit, ) { Scaffold( topBar = { TopAppBar(title = { Text("AndroidView Demo Title") }) }, content = { padding -> AndroidView( modifier = Modifier.padding(padding).wrapContentHeight(), factory = { context -> CustomButtonView(context).apply { setText("Demo Button") setOnClickListener { onButtonClick() } } }, ) } ) }

Slide 91

Slide 91 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView @get:Rule val composeTestRule = createComposeRule() @Test fun composable_withAndroidView() { composeTestRule.apply { // ... } } var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } }

Slide 92

Slide 92 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) Espresso.onView(withText("Demo Button")).perform(click()) assertEquals(true, buttonClicked) var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } }

Slide 93

Slide 93 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) Espresso.onView(withText("Demo Button")).perform(click()) assertEquals(true, buttonClicked) ✅

Slide 94

Slide 94 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) Espresso.onView(withText("Demo Button")).perform(click()) assertEquals(true, buttonClicked) ✅ ✅

Slide 95

Slide 95 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) assertEquals(true, buttonClicked) ✅ ✅ ✅❓ ❌ Espresso.onView(withText("Demo Button")).perform(click())

Slide 96

Slide 96 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView Doesn’t work as of Jetpack Compose 1.3.0-beta03 🙁 https:!//issuetracker.google.com/issues/215231631 Espresso.onView(withText("Demo Button")).perform(click())

Slide 97

Slide 97 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView Espresso.onView(withText("Demo Button")).check { view, _ -> view.performClick() } Unstable workaround https://issuetracker.google.com/issues/215231631

Slide 98

Slide 98 text

#DroidKaigi2022 – @stewemetal Hybrid UI testing - AndroidView var buttonClicked = false setContent { HydrationTrackerTheme { AndroidViewDemo { buttonClicked = true } } } onNodeWithText("AndroidView Demo Title").assertIsDisplayed() Espresso.onView(withText("Demo Button")).check(matches(isDisplayed())) assertEquals(true, buttonClicked) Espresso.onView(withText("Demo Button")).check { view, _ -> view.performClick() } ✅ ✅ ✅ ✅❓

Slide 99

Slide 99 text

#DroidKaigi2022 – @stewemetal Topics to check out • Testing animations in Compose 👀 • Synchronization and IdlingResources 🕞 • Screenshot testing with Paparazzi, Showkase, etc. 📸

Slide 100

Slide 100 text

#DroidKaigi2022 – @stewemetal Topics to check out • Testing animations in Compose 👀 • Synchronization and IdlingResources 🕞 • Screenshot testing with Paparazzi, Showkase, etc. 📸

Slide 101

Slide 101 text

#DroidKaigi2022 – @stewemetal Resources • developer.android.com/jetpack/compose/testing • developer.android.com/jetpack/compose/testing-cheatsheet • developer.android.com/jetpack/compose/accessibility • developer.android.com/jetpack/compose/semantics • youtu.be/kdwofTaEHrs • adbackstage.libsyn.com/episode-171-compose-testing • github.com/android/compose-samples • adavis.info/2021/09/testing-hybrid-jetpack-compose-apps.html • github.com/stewemetal/composehydrationtracker

Slide 102

Slide 102 text

stewemetal How to Test Your Compose UI István Juhos • Testing based on the semantics tree • Planned accessibility = good testability • Testing composables in isolation helps a lot • Hybrid UI testing is supported out of the box Cover photo by Rob Mulally on Unsplash

Slide 103

Slide 103 text

#DroidKaigi2022 – @stewemetal Thank you! 🙏 ありがとうございました