Espresso Custom Matchers: Writing Reusable View Matchers for Your Android App
Espresso's built-in matchers cover most cases, but real Android apps have custom views, complex states, and domain-specific conditions that built-in matchers can't express. Custom matchers let you write tests that read like specifications: onView(withProductName("Widget")) instead of onView(allOf(isDescendantOfA(withId(R.id.productCard)), withId(R.id.productNameLabel), withText("Widget"))).
The Two Matcher Base Classes
TypeSafeMatcher<T> — matches a specific type. Use when you want to check a property of a particular view type:
import org.hamcrest.TypeSafeMatcher
import org.hamcrest.Description
import android.view.View
class HasErrorTextMatcher(private val expectedError: String) : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("has error text: '$expectedError'")
}
override fun matchesSafely(view: View): Boolean {
if (view !is TextInputLayout) return false
return view.error?.toString() == expectedError
}
}
fun hasErrorText(error: String): Matcher<View> = HasErrorTextMatcher(error)BoundedMatcher<T, S> — matches a subtype. More precise than TypeSafeMatcher when you need the view to be a specific subclass:
import org.hamcrest.BaseMatcher
import androidx.test.espresso.matcher.BoundedMatcher
class HasTextColorMatcher(
@ColorRes private val colorRes: Int
) : BoundedMatcher<View, TextView>(TextView::class.java) {
override fun describeTo(description: Description) {
description.appendText("has text color with resource id $colorRes")
}
override fun matchesSafely(textView: TextView): Boolean {
val expectedColor = ContextCompat.getColor(
textView.context, colorRes
)
return textView.currentTextColor == expectedColor
}
}
fun hasTextColor(@ColorRes colorRes: Int): Matcher<View> = HasTextColorMatcher(colorRes)Practical Custom Matchers
TextInputLayout Error Matcher
Built-in withText matches TextView.text, not the error text on TextInputLayout. Custom matcher to fix this:
fun withTextInputLayoutError(expectedError: String): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with TextInputLayout error: '$expectedError'")
}
override fun matchesSafely(view: View): Boolean {
if (view !is TextInputLayout) return false
return view.error?.toString() == expectedError
}
}
}
fun withTextInputLayoutHint(expectedHint: String): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with TextInputLayout hint: '$expectedHint'")
}
override fun matchesSafely(view: View): Boolean {
if (view !is TextInputLayout) return false
return view.hint?.toString() == expectedHint
}
}
}
// Usage
onView(withId(R.id.emailInputLayout))
.check(matches(withTextInputLayoutError("Invalid email address")))Drawable State Matcher
Check if a view has a specific drawable (like a checkmark for a selected state):
fun withDrawable(@DrawableRes drawableRes: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("has drawable resource $drawableRes")
}
override fun matchesSafely(view: View): Boolean {
if (view !is ImageView) return false
val drawable = ContextCompat.getDrawable(view.context, drawableRes) ?: return false
val viewDrawable = view.drawable ?: return false
val viewBitmap = viewDrawable.toBitmap()
val expectedBitmap = drawable.toBitmap()
return viewBitmap.sameAs(expectedBitmap)
}
}
}Matcher for Custom View Properties
If you have a custom view with a status property:
// Custom view
class StatusBadge(context: Context) : AppCompatTextView(context) {
var status: String = "pending"
set(value) {
field = value
text = value.capitalize()
setBackgroundColor(when (value) {
"active" -> Color.GREEN
"pending" -> Color.YELLOW
"inactive" -> Color.GRAY
else -> Color.WHITE
})
}
}
// Custom matcher
fun withStatus(expectedStatus: String): Matcher<View> {
return object : BoundedMatcher<View, StatusBadge>(StatusBadge::class.java) {
override fun describeTo(description: Description) {
description.appendText("StatusBadge with status '$expectedStatus'")
}
override fun matchesSafely(badge: StatusBadge): Boolean {
return badge.status == expectedStatus
}
}
}
// Usage
onView(withId(R.id.accountStatusBadge))
.check(matches(withStatus("active")))RecyclerView Item Count Matcher
fun withItemCount(expectedCount: Int): Matcher<View> {
return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description) {
description.appendText("RecyclerView with $expectedCount items")
}
override fun matchesSafely(recyclerView: RecyclerView): Boolean {
return recyclerView.adapter?.itemCount == expectedCount
}
}
}
// Usage
onView(withId(R.id.productList))
.check(matches(withItemCount(5)))Matcher for View at RecyclerView Position
Check the content of a specific position in a RecyclerView:
fun atPosition(position: Int, itemMatcher: Matcher<View>): Matcher<View> {
return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description) {
description.appendText("has item at position $position: ")
itemMatcher.describeTo(description)
}
override fun matchesSafely(recyclerView: RecyclerView): Boolean {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position)
?: return false
return itemMatcher.matches(viewHolder.itemView)
}
}
}
// Usage
onView(withId(R.id.productList))
.check(matches(atPosition(0, hasDescendant(withText("Widget A")))))Custom ViewAssertions
Beyond matchers, you can create custom ViewAssertion implementations for complex checks:
import androidx.test.espresso.ViewAssertion
fun isCompletelyAbove(otherViewMatcher: Matcher<View>): ViewAssertion {
return ViewAssertion { view, noViewFoundException ->
noViewFoundException?.let { throw it }
// Find the other view
// Note: in practice this requires the view hierarchy — simplified for illustration
val viewRect = Rect()
view.getGlobalVisibleRect(viewRect)
// Real implementation would find the other view through the root view
// and compare positions
}
}For most real-world needs, combining matchers with allOf is cleaner than custom assertions.
Organizing Custom Matchers
Create a file or object for all custom matchers:
// CustomMatchers.kt
object CustomMatchers {
fun withTextInputLayoutError(error: String): Matcher<View> = ...
fun withStatus(status: String): Matcher<View> = ...
fun withItemCount(count: Int): Matcher<View> = ...
fun atPosition(position: Int, matcher: Matcher<View>): Matcher<View> = ...
// Alias for readable tests
fun isLoading(): Matcher<View> = withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)
.and(isAssignableFrom(ProgressBar::class.java))
}
// In your tests — import as extension or static
import com.example.tests.CustomMatchers.withTextInputLayoutErrorOr as extension functions on ViewMatchers:
// Extension functions for cleaner test code
fun withStatus(status: String) = CustomMatchers.withStatus(status)
fun withItemCount(count: Int) = CustomMatchers.withItemCount(count)Debugging Matchers
When a matcher fails, the error message should be clear. Always implement describeTo meaningfully:
override fun describeTo(description: Description) {
// Bad:
description.appendText("custom matcher")
// Good:
description.appendText("TextInputLayout with error text: '$expectedError'")
// Output: Expected: TextInputLayout with error text: 'Invalid email'
// But was: TextInputLayout with error text: ''
}Add describeMismatch for even better diagnostics:
override fun describeMismatchSafely(item: TextInputLayout, mismatchDescription: Description) {
mismatchDescription.appendText("was TextInputLayout with error: '${item.error}'")
}Testing Custom Matchers
Custom matchers should be tested with unit tests (local JVM tests, not instrumented):
// src/test/java — runs on JVM, not device
class CustomMatcherTests {
@Test
fun withTextInputLayoutError_matchesCorrectError() {
val context = ApplicationProvider.getApplicationContext<Context>()
val layout = TextInputLayout(context)
layout.error = "Invalid email"
val matcher = withTextInputLayoutError("Invalid email")
assertTrue(matcher.matches(layout))
}
@Test
fun withTextInputLayoutError_doesNotMatchWrongError() {
val context = ApplicationProvider.getApplicationContext<Context>()
val layout = TextInputLayout(context)
layout.error = "Different error"
val matcher = withTextInputLayoutError("Invalid email")
assertFalse(matcher.matches(layout))
}
@Test
fun withItemCount_matchesCorrectCount() {
val context = ApplicationProvider.getApplicationContext<Context>()
val recyclerView = RecyclerView(context)
val adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = TODO()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {}
override fun getItemCount() = 5
}
recyclerView.adapter = adapter
assertTrue(withItemCount(5).matches(recyclerView))
assertFalse(withItemCount(3).matches(recyclerView))
}
}Domain-Specific Test DSL
Once you have custom matchers, build a small DSL for your domain:
// ProductMatchers.kt
object ProductMatchers {
fun withProductName(name: String): Matcher<View> =
allOf(isDescendantOfA(withId(R.id.productCard)), withId(R.id.productName), withText(name))
fun withProductPrice(price: String): Matcher<View> =
allOf(isDescendantOfA(withId(R.id.productCard)), withId(R.id.productPrice), withText(price))
fun withProductInStock(): Matcher<View> =
allOf(isDescendantOfA(withId(R.id.productCard)), withId(R.id.stockBadge), withStatus("in_stock"))
}
// Tests read like specs
@Test
fun productList_showsCorrectProducts() {
onView(withProductName("Widget A")).check(matches(isDisplayed()))
onView(withProductPrice("$9.99")).check(matches(isDisplayed()))
onView(withProductInStock()).check(matches(isDisplayed()))
}Summary
Custom matchers are the investment that pays off as your Espresso test suite grows. Without them, tests become long chains of allOf, isDescendantOfA, and withId that obscure what's actually being tested. With domain-specific matchers, test methods read like intent and fail with clear diagnostic messages.
The core pattern: identify the view properties you check frequently (error states, custom view properties, collection sizes) and build matchers for them. Test the matchers themselves with JVM unit tests. The result is a test suite where each test method is readable without knowledge of your view hierarchy.