이번에는 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
같은 걸 쓰면 내부 뷰 ( 텍스트나 버튼 )를 건드릴 수 있나?
그렇게는 안된다.
이유는 hasDescendant
는 ViewMathcher
이기 때문이다.
인자로 요구하는 것이 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
에 대해서 정리를 하려 한다.
'안드로이드' 카테고리의 다른 글
Android & Java - Dynamic Proxy (0) | 2020.06.30 |
---|---|
Android Espresso #4 - ActivityRules (0) | 2020.06.29 |
Android Espresso #2 - ViewMatcher, ViewAction, ViewAssertion (0) | 2020.06.25 |
Android Espresso #1 - 시작 (0) | 2020.06.24 |
[And] navigation #2 - 딥 링크 (0) | 2020.06.24 |