Introduction
Finding a View in Espresso tests can be quite confusing because there are so many matchers available. In this tutorial, we will learn how to find a View based on its sibling contents.
Goals
At the end of the tutorial, you would have learned:
- How to match against siblings in Espresso tests.
Tools Required
- Android Studio. The version used in this tutorial is Bumblebee 2021.1.1 Patch 2.
Prerequisite Knowledge
- Basic Android.
- Basic Espresso.
Project Setup
To follow along with the tutorial, perform the steps below:
-
Create a new Android project with the default Empty Activity.
-
Replace the default activity_main.xml with the code below. This replaces the default TextView with a RecyclerView, constrains it, and assign it an
id
.<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView_myRecycler" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
Create a new sample layout called
sample_viewholder.xml
to act as the View for each RecyclerView item. This layout includes 3 TextView objects placed in a horizontal chain.<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/layout_viewHolder" android:layout_marginVertical="16dp" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/textView_name" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/textView_title" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Name" /> <TextView android:id="@+id/textView_title" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/textView_age" app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toEndOf="@+id/textView_name" app:layout_constraintTop_toTopOf="parent" tools:text="Title" /> <TextView android:id="@+id/textView_age" android:layout_width="wrap_content" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/textView_title" app:layout_constraintTop_toTopOf="parent" tools:text="Age" /> </androidx.constraintlayout.widget.ConstraintLayout>
-
Create a new data class called SampleViewHolderUiState to hold the UI state for the sample_viewholder.xml.
data class SampleViewHolderUiState( val name: String, val title: String, val age: Int )
-
Create a new MyAdapter to adapt to SampleViewHolderUiState.
class MyAdapter(private val dataSet: List<SampleViewHolderUiState>) : RecyclerView.Adapter<MyAdapter.SampleViewHolder>() { class SampleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val textViewName: TextView = itemView.findViewById(R.id.textView_name) val textViewTitle: TextView = itemView.findViewById(R.id.textView_title) val textViewAge: TextView = itemView.findViewById(R.id.textView_age) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.sample_viewholder, parent, false) return SampleViewHolder(view) } override fun onBindViewHolder(holder: SampleViewHolder, position: Int) { holder.textViewName.text = dataSet[position].name holder.textViewTitle.text = dataSet[position].title holder.textViewAge.text = "${dataSet[position].age}" } override fun getItemCount() = dataSet.size }
-
Append the code below to initialize the RecyclerView in
MainActivity#onCreate()
.val recyclerView = findViewById<RecyclerView>(R.id.recyclerView_myRecycler) val sampleDataSet = listOf( SampleViewHolderUiState("John", "Student", 17), SampleViewHolderUiState("Mary", "Teacher", 28) ) recyclerView.adapter = MyAdapter(sampleDataSet)
-
It is a good idea to run the App now to see if everything was set up correctly. It should look the same as the screenshot below.
-
To keep things simple, we will just reuse the pre-created ExampleInstrumentedTest file. Update it with the content below.
@RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @get:Rule val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) @Test fun test_matchSibling() { onView(withId(R.id.layout_viewHolder)) .check(matches(isDisplayed())) } }
The Problem
If you attempt to run the test now, then it will fail with the error message below.
androidx.test.espresso.AmbiguousViewMatcherException: 'view.getId() is <2131230949/com.example.daniwebandroidnativematchsiblinginespresso:id/layout_viewHolder>' matches multiple views in the hierarchy.
This is because there are multiple views with the id
of layout_viewHolder
. Our RecyclerView has two different ViewHolders after all. Fortunately, they both contain different text values for all three TextView (name, title, age). To be able to find a specific View, then we will have to dig deeper into the view hierarchy and filter against those inner values.
Match against sibling children
It is possible to find the first layout_viewHolder
View by matching against a sibling and its children. To match against a sibling, we need to use the hasSibling
matcher. Replace the content of test_matchSibling
with the code below.
@Test
fun test_matchSibling() {
onView(allOf(
withId(R.id.layout_viewHolder),
hasSibling(allOf(
withId(R.id.layout_viewHolder),
withChild(allOf(
withId(R.id.textView_name),
withText("Mary"),
hasSibling(allOf(
withId(R.id.textView_title),
withText("Teacher"),
hasSibling(allOf(
withId(R.id.textView_age),
withText("28")
))
))
))
))
))
}
The code above is totally overkill because we actually do not have to go too deep into the sibling’s hierarchy nor match against the sibling’s id
at all. I just wanted to demonstrate how nested hasSibling
looks like when we need to use it. The code below can also find the same View in only one line.
onView(hasSibling(withChild(withText("Mary"))))
Match against own child siblings
We can also match the View against its own children hierarchy. The code below demonstrates this.
@Test
fun test_matchChildrenSiblings(){
onView(allOf(
withId(R.id.layout_viewHolder),
withChild(allOf(
withId(R.id.textView_name),
withText("John"),
hasSibling(allOf(
withId(R.id.textView_title),
withText("Student"),
hasSibling(allOf(
withId(R.id.textView_age),
withText("17")
))
))
))
)).check(matches(isDisplayed()))
//One liner
//onView(withChild(withText("John")))
}
Solution Code
ExampleInstrumentedTest.kt
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@get:Rule
val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun test_matchSibling() {
onView(allOf(
withId(R.id.layout_viewHolder),
hasSibling(allOf(
withId(R.id.layout_viewHolder),
withChild(allOf(
withId(R.id.textView_name),
withText("Mary"),
hasSibling(allOf(
withId(R.id.textView_title),
withText("Teacher"),
hasSibling(allOf(
withId(R.id.textView_age),
withText("28")
))
))
))
))
))
//One liner
//onView(hasSibling(withChild(withText("Mary"))))
}
@Test
fun test_matchChildrenSiblings(){
onView(allOf(
withId(R.id.layout_viewHolder),
withChild(allOf(
withId(R.id.textView_name),
withText("John"),
hasSibling(allOf(
withId(R.id.textView_title),
withText("Student"),
hasSibling(allOf(
withId(R.id.textView_age),
withText("17")
))
))
))
)).check(matches(isDisplayed()))
//One liner
//onView(withChild(withText("John")))
}
}
MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerView_myRecycler)
val sampleDataSet = listOf(
SampleViewHolderUiState("John", "Student", 17),
SampleViewHolderUiState("Mary", "Teacher", 28)
)
recyclerView.adapter = MyAdapter(sampleDataSet)
}
}
MyAdapter.kt
class MyAdapter(private val dataSet: List<SampleViewHolderUiState>)
: RecyclerView.Adapter<MyAdapter.SampleViewHolder>() {
class SampleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val textViewName: TextView = itemView.findViewById(R.id.textView_name)
val textViewTitle: TextView = itemView.findViewById(R.id.textView_title)
val textViewAge: TextView = itemView.findViewById(R.id.textView_age)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SampleViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.sample_viewholder, parent, false)
return SampleViewHolder(view)
}
override fun onBindViewHolder(holder: SampleViewHolder, position: Int) {
holder.textViewName.text = dataSet[position].name
holder.textViewTitle.text = dataSet[position].title
holder.textViewAge.text = "${dataSet[position].age}"
}
override fun getItemCount() = dataSet.size
}
SampleViewHolderUiState
data class SampleViewHolderUiState(
val name: String,
val title: String,
val age: Int
)
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_myRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
sample_viewholder.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout_viewHolder"
android:layout_marginVertical="16dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/textView_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView_title"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Name" />
<TextView
android:id="@+id/textView_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/textView_age"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/textView_name"
app:layout_constraintTop_toTopOf="parent"
tools:text="Title" />
<TextView
android:id="@+id/textView_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/textView_title"
app:layout_constraintTop_toTopOf="parent"
tools:text="Age" />
</androidx.constraintlayout.widget.ConstraintLayout>
Summary
Congratulations, you have learned how to match against siblings in Espresso tests. The full project code can be found at https://github.com/dmitrilc/DaniwebAndroidNativeMatchSiblingInEspresso