LazyList
Ultron 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.
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
, firstItem
, 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 child { hasTestTag(contactNameTestTag) }
val status by child { hasTestTag(contactStatusTestTag) }
}
Note: you have to use delegated initialisation with by child
.
For Compose Multiplatform project you need to register Item class instances with initBlock
param:
composeList(.., initBlock = {
registerItem { ComposeFriendListItem() }
registerItem { AnotherListItem() }
})
It is required cause Kotlin Multiplatfor Project has limited reflation API for different platforms.
You don't need to register Items for Android UI tests.
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 returnsUltronComposeListItem
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.
Efficient Strategies for Locating Items in Compose LazyList
Let's start with approaches that you can use without additional efforts. For example, you have identified LazyList
in your tests code like
val lazyList = composeList(listMatcher = hasTestTag("listTestTag"), ..)
class ComposeListItem : UltronComposeListItem() {
val name by lazy { getChild(hasTestTag(contactNameTestTag)) }
val status by lazy { getChild(hasTestTag(contactStatusTestTag)) }
}
1. ..visibleItem
This is probably the most unstable approach. It's only suitable in case you didn't interact with LazyList
and would like to reach an item that is on the screen.
Use the following methods:
lazyList.firstVisibleItem()
lazyList.visibleItem(index = 3)
lazyList.lastVisibleItem()
lazyList.getFirstVisibleItem<ComposeListItem>()
lazyList.getVisibleItem<ComposeListItem>(index = 3)
lazyList.getLastVisibleItem<ComposeListItem>()
2. Item by unique SemanticsMatcher
A more stable way to find the item is to use SemanticsMatcher
. It allows you to find the item not only on the screen.
lazyList.item(hasAnyDescendant(hasText("Some unique text"))
lazyList.getItem<ComposeListItem>(hasAnyDescendant(hasText("Some unique text"))
The next two approaches require additional code in the application. These are the most stable and preferable ways.
3. Set up positionPropertyKey
By default, a compose list item doesn't have a property that stores its position in the list. We can add this property in a really simple way.
Here is the application code:
// create custom SemanticsPropertyKey
val ListItemPositionPropertyKey = SemanticsPropertyKey<Int>("ListItemPosition")
var SemanticsPropertyReceiver.listItemPosition by ListItemPositionPropertyKey
// specify it for item and store item index in this property
@Composable
fun ContactsListWithPosition(contacts: List<Contact>
) {
LazyColumn(
modifier = Modifier.semantics { testTag = "listTestTag" }
) {
itemsIndexed(contacts) { index, contact ->
Column(
modifier = Modifier.semantics {
listItemPosition = index
}
) {
// item content
}
}
}
}
After that, you need to specify the custom SemanticsPropertyKey
in the test code:
val lazyList = composeList(
listMatcher = hasTestTag("listTestTag"),
positionPropertyKey = ListItemPositionPropertyKey
)
It allows you to reach the item by its position in the list:
lazyList.firstItem()
lazyList.item(position = 25)
lazyList.getFirstItem<ComposeListItem>()
lazyList.getItem<ComposeListItem>(position = 7)
4. Set up item testTag
It is recommended to build testTag
in a separate function based on data object.
For example, let's assume we have a Contact
data class that stores data to be presented in the item.
data class Contact(val id: Int, val name: String, val status: String, val avatar: String)
We can create function to build testTag
based on contact.id
fun getContactItemTestTag(contact: Contact) = "contactId=${contact.id}"
We can use this function in the application code to specify testTag
and in the test code to find the item by testTag
:
// application code
@Composable
fun ContactsListWithPosition(contacts: List<Contact>
) {
LazyColumn(
modifier = Modifier.semantics { testTag = "listTestTag" }
) {
itemsIndexed(contacts) { index, contact ->
Column(
modifier = Modifier.semantics {
listItemPosition = index
testTag = getContactItemTestTag(contact)
}
) {
// item content
}
}
}
}
//test code
val lazyList = composeList(listMatcher = hasTestTag("listTestTag"))
lazyList.item(hasTestTag(getContactItemTestTag(contact)))
lazyList.getItem<ComposeListItem>(hasTestTag(getContactItemTestTag(contact)))