본문 바로가기

안드로이드

Android Espresso #3 - RecyclerView

반응형

이번에는 RecyclerView 관련 액션을 지원하는 RecyclerViewActions에 대해 설명하고,
예제와 함께 RecyclerView의 UI 테스트를 작성하려 한다.

시작하기

androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0'

"espresso-contrib" 라이브러리는 몇 가지 뷰에 대한 추가적인 Matcher와 Action 들을 제공한다.
( 당연히도 직접 구현해도 동일한 기능이 가능하다. )

 

리사이클러뷰에서 사용할 Action 들도 추가가 되어있다.

RecyclerViewActions

RecyclerViewActions 가 바로 RecyclerView를 위해 추가된 Action이다.
총 6개의 Action 이 추가되었고, 스크롤에 대한 처리를 포함하고 있다.

1. scrollTo

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(40)
    )

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(
            hasDescendant(withText("Page 7"))
        ).atPosition(0)
    )

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.scrollToHolder<RecyclerView.ViewHolder>(
            instanceOf(SampleAdapter.PageViewHolder::class.java)
        ).atPosition(30)
    )

스크롤을 위한 Action이다.

 

scrollToPosition 은 포지션을 이용하여 스크롤하는 방식이다.

해당 위치에 아이템이 있기만 한다면 문제없이 동작한다.

( 없어도 스크롤만 안 할 뿐 별 문제는 없다. )

 

scrollTo 는 뷰를 통해 스크롤하는 방식이다.

인자로 찾고자 하는 매처를 넣으면 해당 뷰로 이동한다.

또한, 같은 조건의 뷰가 여럿이 나올 수 있기에 atPosition 함수를 이용해 하나를 선택해 주어야 한다.

 

위의 예제에선 "Page 7"이라는 텍스트를 가진 화면 중에 첫 번째 화면으로 스크롤한 것이다.

 

scrollToHolder 은 뷰 홀더를 통해 스크롤하는 방식이다.

뷰 홀더를 특정할 수 있는 instanceOf 나 커스텀 매처를 인자로 던지면 된다.
역시나 atPosition 을 통해서 여러 개 중 하나를 선택해야 한다.

 

위의 예제에선 "PageViewHolder" 중에서 30번째로 스크롤한 것이다.

 

중요한 점은 세 개의 함수 모두 제네릭 타입을 주입받고 있는데, 어떤 걸 넣어도 차이가 없다.

scrollToHolder<SampleAdapter.PageViewHolder> 이런 식으로 수정한다고 해도 모든 뷰 홀더를 다 탐색한다.

애초에 scrollToPosition 에선 받기만 하고 쓰지도 않는다.

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.scrollToHolder<SampleAdapter.ChapterViewHolder>(
            instanceOf(SampleAdapter.PageViewHolder::class.java)
        ).atPosition(30)
    )

심지어는 이런 식으로 scrollToHolder<SampleAdapter.ChapterViewHolder> 로 수정해도SampleAdapter.PageViewHolder 를 찾아낸다..

고심하지 말고 RecyclerView.ViewHolder를 넣거나 scrollToHolder의 경우 생략하자.

( 이유를 찾으면 "꼭" 말해주자. )

2. actionOn

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
            7, click()
        )
    )

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
            hasDescendant(withText("Page 7")), click()
        ).atPosition(0)
    )

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.actionOnHolderItem(
            instanceOf(SampleAdapter.PageViewHolder::class.java), click()
        ).atPosition(30)
    )

스크롤 후에 바로 동작을 시킬 수 있는 Action 들이다.

코드는 ScrollTo와 비슷하다. 대신, 인자로 Action을 하나 더 받는다.
제네릭이 의미가 없는 것 역시 동일하다.

 

중요한 것은, 위의 예시처럼 작성 시 스크롤 후 click() 을 하게 되는데,

이때 대상은 itemView이다.

 

그럼 앞에 나온 hasDescendant 같은 걸 쓰면 내부 뷰 ( 텍스트나 버튼 )를 건드릴 수 있나?

그렇게는 안된다.


이유는 hasDescendantViewMathcher이기 때문이다.

인자로 요구하는 것이 ViewAction 여서 사용할 수 가없다.
그러니, 내부 뷰에 있는 버튼 같은 것을 클릭하려면 스크롤만 진행 후 화면에 표시된 상태에서 다른 방식으로 진행해야 한다.

실제 적용

RecyclerViewActions에 대해 알아보았으니, 실제로 UI 테스트와 함께 보도록 하겠다.

테스트할 샘플의 기능은 2가지이다.

 

1. 챕터와 페이지가 존재하면, 챕터에는 열고 닫을 수 있는 스위치가 있다. 페이지는 챕터가 닫히면 사라지고, 챕터가 열리면 나타난다.
2. 페이지를 누르면 해당 페이지의 상세화면으로 이동한다.

 

RecyclerView를 사용할 때 가장 기본적인 구성이라 생각해서 위와 같은 샘플을 만들었다.

테스트할 것

1. 화면에 모든 데이터가 잘 나오는지
2. 열고 닫기 과정에서 제대로 동작했는지
3. 상세화면으로 이동 후 원하는 데이터가 표시되었는지

 

이렇게 3가지 UI를 테스트하는 코드를 작성할 것이다.

테스트  0 - 데이터 만들고 집어넣기

@get:Rule
var activityScenarioRule: ActivityScenarioRule<MainActivity> =
    ActivityScenarioRule(MainActivity::class.java)

우선 "Rule"을 지정해주어야 한다.
"Rule" 은 내가 테스트할 동안 어떤 규칙을 따를지 지정하는 것으로
여기선 ActivityScenarioRule 을 사용하고, 화면으로 MainActivity 로 지정하였다.

var chapters = listOf<Chapter>()

@Before
fun setupData() {
    activityScenarioRule.scenario.onActivity {
        (it.findViewById<RecyclerView>(R.id.recyclerView))?.let {
            chapters = (1..Random.nextInt(10, 20)).map { chapterId ->
                Chapter(
                    chapterId,
                    "Chapter $chapterId",
                    (1..Random.nextInt(1, 10)).map { pageId ->
                        Page(pageId, "Page $pageId")
                    })
            }
            (it.adapter as SampleAdapter).setData(chapters)
        }
    }
}

이제 테스트용 데이터를 직접 주입하여 세팅해 줄 것이다.
( 테스트용 Repository 구현하거나 테스트용 json을 작성하든지 자유롭게 하면 된다. )

 

여기서 사용할 데이터인 챕터와 페이지는 각각 "Chapter $chapterId", "Page $pageId"를 타이틀로 가지고 있고,
하나의 챕터는 여러 페이지를 가지고 있다. ( 게다가 랜덤이다!! )

 

액티비티에 어떤 작업을 수행하고 싶으면

activityScenarioRule.scenario.onActivity { do Any Thing } 코드를 사용하면 된다.

 

위의 예제에서 findViewById<RecyclerView>(R.id.recyclerView) 를 이용하여
adapter를 가져오고 데이터를 넣어 주었다.

 

데이터 세팅은 모든 테스트에 필요하므로 Before 어노테이션을 붙여서 매번 동작하도록 만든다.

 

드디어 데이터가 준비되었으니 실제로 테스트를 해보겠다.

테스트 1 - 화면에 모든 데이터가 나왔는가?

var position = 0

//1
chapters.forEach {
	//2
    onView(withId(R.id.recyclerView))
        .perform(
            RecyclerViewActions.scrollToPosition<SampleAdapter.ChapterViewHolder>(
                position
            )
        )//3
        .check(matches(hasDescendant(allOf(withText(it.title), isDisplayed()))))
		
        //4
    	position++
		
        //5
      	it.pages.forEach {
        	//6
        	onView(withId(R.id.recyclerView))
            	.perform(
                RecyclerViewActions.scrollToPosition<SampleAdapter.PageViewHolder>(
                    position
                )
            )//7
            .check(matches(hasDescendant(allOf(withText(it.desc), isDisplayed()))))
			
            //8
         	position++
    }
}

위 과정을 요약하면 position을 하나씩 옮기면서 모든 화면이 보이고, 값이 정확한지를 테스트하는 코드이다.

 

순서대로 보자면


1. 챕터를 대상으로 반복을 시작한다.
2. recyclerView에서 0 번째 뷰 ( 챕터 )를 들고 온다.
3. 해당 뷰 ( 챕터 )의 자식 중이 0번째 챕터와 동일한 Text를 가지고 있고, 화면에 보이는지 확인하다.
4. position을 1로 바꾼다. ( 페이지의 위치 )
5. 현재 챕터의 페이지를 대상으로 반복을 시작한다.
6. recyclerView에서 1 번째 뷰 ( 페이지 )를 들고 온다.
7. 해당 뷰 ( 페이지 )의 자식 중이 챕터의 첫 번째 페이지와 동일한 Text를 가지고 있고, 화면에 보이는지 확인하다.
8. position을 2로 바꾼다. ( 다음 페이지 또는 다음 챕터의 위치 )
9. 페이지가 없을 때까지 반복한다.
10. 챕터가 없을 때까지 반복한다.


정도로 정리가 되며, 코드는 앞에서 다뤘기에 자세하게는 다루지 않겠다.

이제 데이터가 다 그려졌는지, 순서가 맞는지, 내용이 다 정확한지에 대한 테스트가 완성됐다.

테스트 2 - 열고 닫기 과정에서 제대로 동작했는가?

테스트를 하기 전에 테스트에 필요한 함수들을 만들어 볼 것이다.

private fun getMatchedViewInRecyclerView(@IdRes recyclerViewId: Int, position: Int, @IdRes resId: Int? = null): Matcher<View> {
    return object : CustomTypeSafeMatcher<View>("get matched view in recyclerView") {
        override fun matchesSafely(item: View): Boolean {
            val recyclerView = item.rootView.findViewById<RecyclerView>(recyclerViewId)
            val targetViewHolder = recyclerView.findViewHolderForAdapterPosition(position)

            return resId?.let {
                val targetView = targetViewHolder?.itemView?.findViewById<View>(resId)
                targetView == item
            } ?: run {
                item == targetViewHolder?.itemView
            }
        }
    }
}

getMatchedViewInRecyclerView 함수는 recyclerView의 특정 위치에 있는 특정 뷰를 찾는 코드이다.

RecyclerViewActions과 기타 다른 Action들 만으로는 해당 동작이 어렵기에 직접 구현하였다.

 

코드를 보자면
타깃이 될 recyclerViewId 를 받고, 이를 통해 adapter를 가져온다.

그다음 position 에 맞는 viewHolder를 가져오고, 거기서 resId 를 통해서 찾고자 하는 뷰를 얻는다.

마지막으로 찾고자 하는 뷰와 동일한 뷰가 해당 매처에 들어오면 true를 반환해주는 코드이다.

private fun withRecyclerViewAtPosition(@IdRes recyclerViewId: Int, position: Int, @IdRes resId: Int? = null): Matcher<View> {
    onView(withId(recyclerViewId)).perform(
        RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(position)
    )
    return getMatchedViewInRecyclerView(recyclerViewId, position, resId)
}

또 다른 함수로 withRecyclerViewAtPosition 라는 함수도 만들었다.

해당 역할은 위치까지 스크롤을 하고 위에서 만든 getMatchedViewInRecyclerView 매처를 리턴해주는 것뿐이다.

private fun checkSwitch(isCheck: Boolean): ViewAction {
    return object : ViewAction {
        override fun getDescription(): String = "click target switch $isCheck"

        override fun getConstraints(): Matcher<View> = allOf(
            isAssignableFrom(SwitchCompat::class.java),
            isDisplayed()
        )

        override fun perform(uiController: UiController?, view: View?) {
            if (view is SwitchCompat) {
                view.isChecked = isCheck
            }
            uiController?.loopMainThreadUntilIdle()
        }

    }
}

 

마지막으로 챕터의 스위치의 상태를 변경해줄 ViewAction 이 필요하다.
( 있을 거 같은데 왜 없지.. )

 

간단하다.

뷰로 SwitchCompat 이 오면 상태를 변경해준다.


중요한 건 uiController?.loopMainThreadUntilIdle() 를 빼먹지 않는 것이다.

해당 함수가 있어야 수정사항이 화면에 반영되는 것을 기다려준다.

 

이제 준비가 되었으니 사용해 보자.

1. 모든 스위치 닫기

var position = 0

chapters.forEach {
    onView(withRecyclerViewAtPosition(R.id.recyclerView, position, R.id.chapter_toggle))
        .perform(clickSwitch(false))
        .check(matches(isNotChecked()))

    position++
}

이번에도 position을 옮겨 가면서 테스트를 진행한다.


R.id.recyclerView 에서 position 에 위치한 스위치 (R.id.chapter_toggle) 를 찾도록 지시하였다.

그리고 찾은 후에 clickSwitch(false) 를 통해 화면에 챕터를 닫았다.

마지막으로 실제로 상태가 변하였는지를 check(matches(isNotChecked())) 를 통해 확인하였다.

2. 모든 스위치 열기

position = 0

chapters.forEach {
    onView(withRecyclerViewAtPosition(R.id.recyclerView, position, R.id.chapter_toggle))
        .perform(clickSwitch(true))
        .check(matches(isChecked()))

    position += it.pages.size + 1
}

닫는 것과 동일한 과정이다.

대신 position에 페이지가 반영될 수 있도록 수정되었다.

 

위의 두 과정을 진행하면 챕터가 제대로 열리고 닫히는지에 대한 테스트는 완성됐다.

테스트 3 - 상세화면이 제대로 열리는가?

마지막 테스트답게 간단하다.

onView(withId(R.id.recyclerView))
    .perform(
        RecyclerViewActions.actionOnItem<SampleAdapter.PageViewHolder>(
            hasDescendant(withText("Page 1")), click()
        ).atPosition(0)
    ).check(doesNotExist())

"Page 1"이라 쓰인 페이지 중 첫 번째 페이지를 찾아서 클릭을 한다.
그 후 check(doesNotExist()) 통해 화면에서 존재하지 않는지를 확인한다.

 

화면에 존재 여부를 확인하는 이유는

이를 통해 상세화면이 열렸단 것을 확인할 수 있기 때문이다.

onView(withId(R.id.detail_desc))
    .check(matches(withText("Page 1")))
    .perform(pressBack())
    .check(doesNotExist())

상세 페이지에선 텍스트 ( R.id.detail_desc )에 "Page 1" 이란 글자가 제대로 표시되는지 확인한다.
뒤로 가기 버튼을 누른 후 화면에 존재하지 않는지를 확인한다.

 

이 역시 제대로 상세화면이 닫혔는지 확인하기 위해서다.

이렇게, 마지막 테스트인 상세화면을 제대로 열리는지에 대한 테스트도 완성됐다.

마무리

오늘은 RecyclerViewActions를 배웠고, 커스텀한 매처, 액션도 구현해보았다.
이를 통해 RecyclerView를 테스트하는 방법도 알아보았다.

#4 에선 Rule 에 대해서 정리를 하려 한다.

반응형