ViewMatchers를 커스텀하여 사용하기

권민주·2025년 10월 19일

안드로이드

목록 보기
20/23
post-thumbnail

1. ViewMatcher

1)개념

  • Matcher<? super View> 타입을 구현해 View 객체를 판별하는 규칙을 제공하는 인터페이스
  • Espresso에서는 여러 표준 ViewMatchers(withId, withText, isDisplayed 등)를 제공하며 모두 Matcher<? super View> 형태로 onView()에 전달

2)onView()

  • onView(Matcher<? super View> viewMatcher)는 현재 activity의 view 계층을 탐색하여 전달한 matcher와 일치하는 view를 찾고 ViewInteraction 객체를 반환
  • 반환된 ViewInteraction을 통해 perform() 또는 check() 수행

2. Matcher

  • Espresso는 내부적으로 Hamcrest 라이브러리의 Matcher<T> 인터페이스 사용. 즉, ViewMatchersRootMatchers는 Hamcrest의 Matcher 구현체
  • Matcher는 Object 타입을 받기 때문에 매칭 시 매번 타입 캐스팅이 필요. 타입이 다르면 ClassCastException 발생.
  • TypeSafeMatcher<T> 추상 클래스는 제네릭 타입을 명시하여 매칭 시 타입 안정성을 보장
  • TypeSafeMatcher<View> 또는 TypeSafeMatcher<Root>를 상속해 커스텀 matcher를 만듦

3. TypeSafeMatcher

1)개념

  • Hamcrest가 제공하는 추상 클래스
  • final로 구현된 matches(Object item)가 있고 내부에서 instanceof 검사 후 matchesSafely(T item) 호출
  • 개발자는 타입 캐스팅을 직접 할 필요 없이 안전한 타입(T) 으로 매칭 로직만 작성하면 됨

2)matches(Object item)

  • Matcher 인터페이스의 메서드를 final로 오버라이드한 메서드. final로 막아 matchesSafely(T item)를 오버라이드하게 함
  • 실제 매칭 로직이 수행되는 진입점
  • 내부 동작: item이 null인지 확인. item이 expectedType과 호환되는지 instanceof 검사 진행. 해당 조건들이 모두 통과되면 matchesSafely(T item)을 호출. 즉, 타입 검사를 자동으로 처리해 주며 개발자는 안전하게 matchesSafely()만 구현하면 됨
@Override
public final boolean matches(Object item) {
    return item != null
        && expectedType.isInstance(item)
        && matchesSafely((T) item);
}

3)matchesSafely(T item)

  • 필수 구현 메서드. 개발자가 직접 구현해야 하는 핵심 메서드
  • 타입이 이미 검증된 객체에 대해 실제 매칭 조건을 정의
  • view의 텍스트, 색상, 상태, 속성 등의 비교 로직 작성.
override fun matchesSafely(view: View): Boolean {
    return (view as TextView).text == "OK"
}

4)describeTo(Description description)

  • 필수 구현 메서드
  • Matcher가 기대하는 상태를 설명하는 메서드
  • 테스트 실패 시 출력되는 메시지에 포함
  • 단순히 무엇을 예상했는지를 기술
override fun describeTo(description: Description) {
    description.appendText("has text 'OK'")
}

5)describeMismatch(Object item, Description mismatchDescription)

  • 선택적 재정의 메서드. 기본 구현 존재
  • 매칭이 실패했을 때 실제 값이 어떤 상태였는지를 설명하고 싶을 때 사용
override fun describeMismatch(item: Any?, mismatchDescription: Description) {
    val view = item as? TextView
    mismatchDescription.appendText("was '${view?.text}'")
}

6)생성자

  • TypeSafeMatcher(): 리플렉션으로 제네릭 타입 추론
  • TypeSafeMatcher(Class<?> expectedType): 직접 검사할 타입을 명시
  • TypeSafeMatcher<View>처럼 명시적으로 사용하면 보통 기본 생성자를 사용

7)Espresso에서의 사용 흐름

  • onView(matcher) 호출 시 모든 view에 대해 matcher.matches(view) 호출
  • TypeSafeMatcher는 내부에서 instanceof 검사 후, matchesSafely(view)만 호출
  • 개발자는 view 타입 캐스팅 없이 안전하게 로직 작성

4. Root

  • androidx.test.espresso.Root 클래스
  • 하나의 화면(Window)에 해당하는 최상위 뷰 계층(root view)을 감싸는 객체
  • 일반적으로 앱에는 여러 개의 Root가 존재(activity의 기본 윈도우, Dialog, 팝업, Toast)
  • Espresso는 테스트 중 여러 Root들을 추적. 기본적으로 onView()는 현재 포커스를 가진 Root(activity 윈도우) 에서만 view를 탐색.
  • Toast나 Dialog 같은 별도 윈도우는 이 기본 Root에 포함되지 않기 때문에, .inRoot(matcher)를 사용해 Root를 지정 필수

5. 사용 예시

1)Toast 검사

  • Toast는 activity의 view 계층과는 다른 별도 윈도우로 뜨므로 일반 onView(...)만으로는 확인이 어려움
  • TypeSafeMatcher<Root>를 상속해 matchesSafely(root)를 구현하면 해당 Root(윈도우)가 Toast인지 판별 가능
  • !root.decorView.hasWindowFocus() 처럼 포커스가 없는 윈도우를 Toast로 간주
  • root.windowLayoutParams.get().type == WindowManager.LayoutParams.TYPE_TOAST처럼 윈도우 타입 검사도 가능
  • 방법마다 Android 버전/디바이스에서 동작이 달라져 불안정. 실제로 Android 11+에서 toast 검사로 실패하는 케이스가 존재.
  • 대안 방법
    • 의존성 주입으로 toast 노출을 대체. 테스트용 더미 구현을 주입해 toast 호출을 검증하거나 호출 기록을 확인
    • 테스트 용도의 Snackbar나 레이아웃에 포함된 커스텀 view 사용
    • Toast가 짧게 뜨므로 검사 시 타이밍 맞추기 어려움. 타이밍을 위한 너무 긴 대기에 주의.
class ToastMatcher : TypeSafeMatcher<Root?>() {

    override fun describeTo(description: Description?) {
        description?.appendText("is toast")
    }

    override fun matchesSafely(root: Root?): Boolean {
        if (root == null) return false
        val type = root.windowLayoutParams?.get()?.type ?: return false
        // TYPE_TOAST를 직접 확인(환경에 따라 다르게 동작할 수 있음)
        if (type == WindowManager.LayoutParams.TYPE_TOAST) {
            val windowToken = root.decorView.windowToken
            val appToken = root.decorView.applicationWindowToken
            // 같은 토큰이면 애플리케이션 윈도우 (true = 일반 윈도우 아님)
            return windowToken === appToken.not()
        }
        return false
    }
}

2)특정 속성이 기본 matcher에 없는 경우

  • 글자색, 배경색, 이미지 리소스 같은 시각적 속성은 withTextwithId로는 판별 불가능
class WithTextColor(private val expectedColor: Int) : TypeSafeMatcher<View>() {
    override fun describeTo(description: Description) {
        description.appendText("with text color: $expectedColor")
    }

    override fun matchesSafely(view: View): Boolean {
        val textView = view as? TextView ?: return false
        return textView.currentTextColor == expectedColor
    }
}
onView(withId(R.id.statusLabel))
    .check(matches(WithTextColor(Color.RED)))

3)View 계층 구조로 특정 패턴을 찾아야 할 때

  • 특정 부모 안에 특정 텍스트를 가진 자식이 존재하는지 같은 구조적 조건이 필요한 경우
fun hasChildWithText(text: String): Matcher<View> {
    return object : TypeSafeMatcher<View>() {
        override fun describeTo(description: Description) {
            description.appendText("has child with text: $text")
        }

        override fun matchesSafely(view: View): Boolean {
            if (view !is ViewGroup) return false
            return (0 until view.childCount).any {
                val child = view.getChildAt(it)
                (child as? TextView)?.text == text
            }
        }
    }
}

4)RecyclerView, AdapterView 항목 내부 속성을 검증할 때

  • RecyclerView나 ListView의 각 항목 내부 view의 상태를 확인할 때, onView(withText(...))로는 찾기 어려움
fun atPosition(position: Int, itemMatcher: Matcher<View>): Matcher<View> {
    return object : TypeSafeMatcher<View>() {
        override fun describeTo(description: Description) {
            description.appendText("has item at position $position: ")
            itemMatcher.describeTo(description)
        }

        override fun matchesSafely(view: View): Boolean {
            val recycler = view as? RecyclerView ?: return false
            val holder = recycler.findViewHolderForAdapterPosition(position)
                ?: return false
            return itemMatcher.matches(holder.itemView)
        }
    }
}

ChatGPT의 도움을 받아 작성된 포스팅입니다

profile
안드로이드 개발자:D

0개의 댓글