Espresso Custom Matchers: Writing Reusable View Matchers for Your Android App

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.withTextInputLayoutError

Or 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.

Read more

Start now free