Skip to content

Compose

Aleksei Tiurin edited this page Aug 20, 2023 · 7 revisions

Android compose testing API

Typical android test looks smth like this:

class MyComposeTest {
    @get:Rule
    val composeTestRule = createComposeRule<YourActivity>()
    @Test
    fun myTest() {
        composeTestRule.setContent { .. } // if it's required
        composeTestRule.onNode(hasTestTag("Continue")).performClick()
        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

You can read more aboit it here

So, all compose testing APIs are provided by composeTestRule. It's definitely uncomfortable. Moreover, in case your UI loading takes some time, e.g. in integration test, an assertion or an action fails.

Ultron framework solves all these problems and do a lot more.

Ultron compose

Just create compose rule using Ultron static method

@get:Rule
val composeTestRule = createUltronComposeRule<YourActivity>()

After that you're able to perform stable compose operations in ANY class. Just create a SemanticsMatcher(like hasTestTag("smth")) and call an operation on it. e.g.

hasTestTag("Continue").click()
hasText("Welcome").assertIsDisplayed()

SemanticsMatcher object is used in android compose testing framework to find a target node to interact with.

Ultron compose API

The framework provides an extended API for compose UI testing.

//config
fun withTimeout(timeoutMs: Long)  // to change an operation timeout from default one
fun withResultHandler(resultHandler: (ComposeOperationResult<UltronComposeOperation>) -> Unit) // provide a scope to modify operation result processing
fun <T> isSuccess(action: UltronComposeSemanticsNodeInteraction.() -> T): Boolean
fun withAssertion(assertion: OperationAssertion)
fun withAssertion(name: String = "", isListened: Boolean = false, block: () -> Unit)

//actions
fun click(option: ClickOption? = null)
fun clickCenterLeft(option: ClickOption? = null)
fun clickCenterRight(option: ClickOption? = null)
fun clickTopCenter(option: ClickOption? = null)
fun clickTopLeft(option: ClickOption? = null)
fun clickTopRight(option: ClickOption? = null)
fun clickBottomCenter(option: ClickOption? = null)
fun clickBottomLeft(option: ClickOption? = null)
fun clickBottomRight(option: ClickOption? = null)
fun longClick(option: LongClickOption? = null)
fun longClickCenterLeft(option: LongClickOption? = null)
fun longClickCenterRight(option: LongClickOption? = null)
fun longClickTopCenter(option: LongClickOption? = null)
fun longClickTopLeft(option: LongClickOption? = null)
fun longClickTopRight(option: LongClickOption? = null)
fun longClickBottomCenter(option: LongClickOption? = null)
fun longClickBottomLeft(option: LongClickOption? = null)
fun longClickBottomRight(option: LongClickOption? = null)
fun doubleClick(option: DoubleClickOption? = null)
fun doubleClickCenterLeft(option: DoubleClickOption? = null)
fun doubleClickCenterRight(option: DoubleClickOption? = null)
fun doubleClickTopCenter(option: DoubleClickOption? = null)
fun doubleClickTopLeft(option: DoubleClickOption? = null)
fun doubleClickTopRight(option: DoubleClickOption? = null)
fun doubleClickBottomCenter(option: DoubleClickOption? = null)
fun doubleClickBottomLeft(option: DoubleClickOption? = null)
fun doubleClickBottomRight(option: DoubleClickOption? = null)
fun swipeDown(option: ComposeSwipeOption? = null)
fun swipeUp(option: ComposeSwipeOption? = null)
fun swipeLeft(option: ComposeSwipeOption? = null)
fun swipeRight(option: ComposeSwipeOption? = null)
fun scrollTo()
fun scrollToIndex(index: Int)
fun scrollToKey(key: String)
fun scrollToNode(matcher: SemanticsMatcher)
fun imeAction()
fun pressKey(keyEvent: KeyEvent)
fun getText(): String?
fun inputText(text: String)
fun typeText(text: String)
fun inputTextSelection(selection: TextRange)
fun setSelection(startIndex: Int = 0, endIndex: Int = 0, traversalMode: Boolean)
fun selectText(range: TextRange)
fun clearText()
fun replaceText(text: String)
fun copyText()
fun pasteText()
fun cutText()
fun setText(text: String)
fun setText(text: AnnotatedString)
fun collapse()
fun expand()
fun dismiss()
fun setProgress(value: Float)
fun captureToImage(): ImageBitmap

fun performMouseInput(block: MouseInjectionScope.() -> Unit)
fun performSemanticsAction(key: SemanticsPropertyKey<AccessibilityAction<() -> Boolean>>) 
fun perform(params: UltronComposeOperationParams? = null, block: (SemanticsNodeInteraction) -> Unit)
fun <T> execute(params: UltronComposeOperationParams? = null, block: (SemanticsNodeInteraction) -> T): T

fun getNode(): SemanticsNode
fun <T> getNodeConfigProperty(key: SemanticsPropertyKey<T>): T

//asserts
fun assertIsDisplayed()
fun assertIsNotDisplayed() 
fun assertExists()
fun assertDoesNotExist()
fun assertIsEnabled() 
fun assertIsNotEnabled() 
fun assertIsFocused() 
fun assertIsNotFocused() 
fun assertIsSelected() 
fun assertIsNotSelected()
fun assertIsSelectable()
fun assertIsOn() 
fun assertIsOff() 
fun assertIsToggleable() 
fun assertHasClickAction() 
fun assertHasNoClickAction() 
fun assertTextEquals(vararg expected: String, option: TextEqualsOption? = null)
fun assertTextContains(expected: String, option: TextContainsOption? = null)
fun assertContentDescriptionEquals(vararg expected: String)
fun assertContentDescriptionContains(expected: String, option: ContentDescriptionContainsOption? = null)
fun assertValueEquals(expected: String) 
fun assertRangeInfoEquals(range: ProgressBarRangeInfo)
fun assertHeightIsAtLeast(minHeight: Dp) 
fun assertHeightIsEqualTo(expectedHeight: Dp)
fun assertWidthIsAtLeast(minWidth: Dp) 
fun assertWidthIsEqualTo(expectedWidth: Dp) 
fun assertMatches(matcher: SemanticsMatcher, messagePrefixOnError: (() -> String)? = null) 

Best practice - Compose PageObject pattern

Specify page elements as properties of Page class

object SomePage : Page<SomePage>() {
    private val button = hasTestTag(ComposeTestTags.button)
    private val eventStatus = hasTestTag(ComposeTestTags.eventStatus)
}

Here ComposeTestTags could be an object that stores testTag constants.

Use this properties in page steps

object SomePage : Page<SomePage>() {
    //page elements
    fun someUserStepOnPage(expectedEventText: String) = apply {
         button.click()
         eventStatus.assertTextContains(expectedEventText)
    }
}

Ultron compose LazyColumn/LazyRow

It's pretty much familiar with UltronRecyclerView approach. The difference is in internal structure of RecyclerView and LazyColumn/LazyRow. Due to implementation features of LazyColumn/LazyRow we can't predict where matched item is located in list without scrolling (actually we can but it takes additional efforts from development)

Before we go forward we need to clarify some terms:

  • ComposeList - list of some items. It's typically implemented in application as LazyColumnt or LazyRow. Ultron has a class that wraps an interaction with list - UltronComposeList.
  • ComposeListItem - single item of ComposeList (there is a class UltronComposeListItem)
  • ComposeListItemChild - child element of ComposeListItem (just a term, there is no special class to work with child elements). So ComposeListItemChild could be considered as a simple compose node.

lazyColumn

UltronComposeList

Create an instance of UltronComposeList by calling a method composeList(..)

composeList(hasTestTag(contactsListTestTag)).assertNotEmpty()

Best practice - define UltronComposeList object as page class property

object ContactsListPage : Page<ContactsListPage >() {
   val lazyList = composeList(hasContentDescription(contactsListContentDesc))
    fun someStep(){
        lazyList.assertNotEmpty() 
        lazyList.assertContentDescriptionEquals(contactsListContentDesc)
    }
}

UltronComposeList API

withTimeout(timeoutMs: Long) // defines a timeout for all operations 
//assertions
fun assertIsDisplayed() 
fun assertIsNotDisplayed()
fun assertExists() 
fun assertDoesNotExist()
fun assertContentDescriptionEquals(vararg expected: String)
fun assertContentDescriptionContains(expected: String, option: ContentDescriptionContainsOption? = null)
fun assertNotEmpty()
fun assertEmpty()
fun assertVisibleItemsCount(expected: Int) 

//item providers for simple UltronComposeListItem
fun item(matcher: SemanticsMatcher): UltronComposeListItem
fun visibleItem(index: Int): UltronComposeListItem
fun firstVisibleItem(): UltronComposeListItem
fun lastVisibleItem(): UltronComposeListItem

// ----- item providers for UltronComposeListItem subclasses -----
// following methods return a generic type T which is a subclass of UltronComposeListItem
fun getItem(matcher: SemanticsMatcher): T
fun getVisibleItem(index: Int): T
fun getFirstVisibleItem(): T 
fun getLastVisibleItem(): T

//interaction provider
visibleChild(matcher: SemanticsMatcher)  // provides an interaction on visible matched item

//actions
fun getVisibleItemsCount(): Int
fun scrollToNode(itemMatcher: SemanticsMatcher)
fun scrollToIndex(index: Int) 
fun scrollToKey(key: Any)
/**
* Provide a scope with references to list SemanticsNode and SemanticsNodeInteraction.
* It is possible to evaluate any action or assertion on this node.
*/
fun <T> performOnList(block: (SemanticsNode, SemanticsNodeInteraction) -> T): T

useUnmergedTree

It is really important to understand the difference btwn merged and unmerged tree. There is a property useUnmergedTree that defines a behaviour.

composeList(hasTestTag(contactsListTestTag), useUnmergedTree = false)
  • By default UltronComposeList uses unmerged tree (useUnmergedTree = true). All child elements contain info in seperate nodes.
  • In case we use merged tree (useUnmergedTree = false) all child elements of item is merged to single node. So you're not able to identify a text value of concrete child.

Why it's important? Cause you need to use different SemanticsMatchers to find appropriate child.

mergedTreeList.item(hasText(contact.name)) // contact.name could be placed in wrong child
unmergedList.item(hasAnyDescendant(hasText(contact.name) and hasTestTag(contactNameTestTag))) //it's longer but certainly provides target node

UltronComposeListItem

UltronComposeList provides an access to UltronComposeListItem

There is a set of methods to create UltronComposeListItem. It's listed upper in UltronComposeList api.

Simple UltronComposeListItem

If you don't need to interact with item child just use methods like item, visibleItem, firstVisibleItem, lastVisibleItem

listWithMergedTree.item(hasText(contact.name)).assertTextContains(contact.name)
listWithMergedTree.firstVisibleItem()
    .assertIsDisplayed()
    .assertTextContains(contact.name)
    .assertTextContains(contact.status)

You don't need to worry about scroll to item. It's executed automatically.

Complex UltronComposeListItem with children

It's often required to interact with item child. The best solution will be to describe children as properties of UltronComposeListItem subclass.

class ComposeFriendListItem : UltronComposeListItem(){
    val name by lazy { getChild(hasTestTag(contactNameTestTag)) }
    val status by lazy { getChild(hasTestTag(contactStatusTestTag)) }
}

Note: you have to use lazy initialisation here.

Now you're able to get ComposeFriendListItem object using methods getItem, getVisibleItem, getFirstVisibleItem, getLastVisibleItem

lazyList.getFirstVisibleItem<ComposeFriendListItem>()
lazyList.getVisibleItem<ComposeFriendListItem>(index)
lazyList.getItem<ComposeFriendListItem>(hasTestTag(..))

Best practice - add a method to Page class that returns UltronComposeListItem subclass

Mark such methods with private visibility modifier. e.g. getContactItem

object ComposeListPage : Page<ComposeListPage>() {
    private val lazyList = composeList(hasContentDescription(contactsListContentDesc))
    private fun getContactItem(contact: Contact): ComposeFriendListItem = lazyList.getItem(hasTestTag(contact.id))

    class ComposeFriendListItem : UltronComposeListItem(){
        val name by lazy { getChild(hasTestTag(contactNameTestTag)) }
        val status by lazy { getChild(hasTestTag(contactStatusTestTag)) }
    }
}

Use getContactItem in Page steps like assertContactStatus

object ComposeListPage : Page<ComposeListPage>() {
    private fun getContactItem(contact: Contact): ComposeFriendListItem = lazyList.getItem(hasTestTag(contact.id))
    ...
    fun assertContactStatus(contact: Contact) = apply {
         getContactItem(contact).status.assertTextEquals(contact.status)
    }
}

UltronComposeListItem API

It's pretty much the same as simple node api, but extends it mostly for internal features.

Extend framework with your own compose operations

Under the hood all Ultron compose operations are described in UltronComposeSemanticsNodeInteraction class. That is why you just need to extend this class using kotlin extension function, e.g.

//new semantic matcher for assertion
fun hasProgress(value: Float): SemanticsMatcher = SemanticsMatcher.expectValue(GetProgress, value)

//add new operation
fun UltronComposeSemanticsNodeInteraction.assertProgress(expected: Float) = apply {
    executeOperation(
        operationBlock = { semanticsNodeInteraction.assert(hasProgress(expected)) },
        name = "Assert '${semanticsNodeInteraction.getDescription()}' has progress $expected",
        description = "Compose assertProgress = $expected in '${semanticsNodeInteraction.getDescription()}' during $timeoutMs ms",
    )
}

//extend SemanticsMatcher with your new operation
fun SemanticsMatcher.assertProgress(expected: Float) = UltronComposeSemanticsNodeInteraction(this).assertProgress(expected)

How to use

val progress = 0.7f
hasTestTag(ComposeElementsActivity.progressBar).setProgress(progress).assertProgress(progress)

You may ask what is GetProgress?

This is a feature of Compose framework. It's available to extend you app with custom SemanticsPropertyKey. Define it in app and assert it in tests.

//application code
@Composable
fun LinearProgressBar(statusState: MutableState<String>){
    val progressState = remember {
        mutableStateOf(0f)
    }
    LinearProgressIndicator(progress = progressState.value, modifier =
    Modifier
        .semantics {
            testTag = ComposeElementsActivity.progressBar
            setProgress { value ->
                progressState.value = value
                statusState.value = "set progress $value"
                true
            }
            progressBarRangeInfo = ProgressBarRangeInfo(progressState.value, 0f..progressState.value, 100)
        }
        .getProgress(progressState.value)
        .progressSemantics()
    )
}

val GetProgress = SemanticsPropertyKey<Float>("ProgressValue")
var SemanticsPropertyReceiver.getProgress by GetProgress

fun Modifier.getProgress(progress: Float): Modifier {
    return semantics { getProgress = progress }
}