관리 메뉴

나만을 위한 블로그

[Android] 리사이클러뷰 UI test 작성하기 본문

Android

[Android] 리사이클러뷰 UI test 작성하기

참깨빵위에참깨빵 2023. 9. 24. 12:14
728x90
반응형

예전에 자바로 안드로이드 리사이클러뷰의 UI test를 작성하는 예시를 포스팅한 적이 있다.

 

https://onlyfor-me-blog.tistory.com/446

 

[Android] espresso를 사용한 UI 테스트(+리사이클러뷰)

이전 글들에선 순수 자바 로직만 테스트했다면 이젠 UI도 테스트해야 한다. UI를 테스트할 때는 주로 에스프레소라는 라이브러리를 사용하는데, 부분적으로 hamcrest라는 단위 테스트 라이브러리

onlyfor-me-blog.tistory.com

 

이번엔 코틀린으로 리사이클러뷰 UI test를 작성하는 예시를 정리한다. 먼저 아래 라이브러리를 추가해야 한다. 리사이클러뷰 UI test에 필요한 클래스가 이 라이브러리를 추가해야 사용할 수 있기 때문이다.

 

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

 

이제 UI test에 사용할 액티비티를 만들어야 한다. 대충 아래 코드들을 적절하게 프로젝트에 복붙한다.

 

<!-- text_row_item.xml  -->
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="text"
            type="String" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="72dp"
        android:layout_marginHorizontal="16dp">

        <TextView
            android:id="@+id/textview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="@{text}"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".recyclerview.uitest.RecyclerViewUiTestActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_test"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>

</layout>
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.kotlinprac.BaseActivity
import com.example.kotlinprac.R
import com.example.kotlinprac.databinding.ActivityRecyclerViewUiTestBinding

class RecyclerViewUiTestActivity :
    BaseActivity<ActivityRecyclerViewUiTestBinding>(R.layout.activity_recycler_view_ui_test) {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val dataSet: MutableList<String> = mutableListOf()
        for (i in 0 until 10) {
            dataSet.add("글자 $i")
        }

        bind {
            rvTest.apply {
                layoutManager = LinearLayoutManager(this@RecyclerViewUiTestActivity)

                val adapter = CustomAdapter(dataSet)
                setAdapter(adapter)
            }
        }
    }
}
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.example.kotlinprac.databinding.TextRowItemBinding

class CustomAdapter(
    private val list: List<String>
) : RecyclerView.Adapter<CustomAdapter.ViewHolder>() {

    private lateinit var binding: TextRowItemBinding

    inner class ViewHolder(binding: TextRowItemBinding) :
        RecyclerView.ViewHolder(binding.root) {
            fun bind(item: String) {
                binding.text = item
            }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        binding =
            TextRowItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val item = list[position]
        holder.bind(item)
    }

    override fun getItemCount(): Int = list.size
}

 

실행 후 import문을 적절히 수정하고, 실행되는 걸 확인한 다음 테스트 코드를 작성한다.

UI test기 때문에 androidTest가 적힌 패키지에 테스트 파일을 작성해야 하는 것에 주의한다.

 

import android.view.View
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.example.kotlinprac.recyclerview.uitest.RecyclerViewUiTestActivity
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
@LargeTest
class RecyclerViewTest {
    @JvmField
    @Rule
    val activityScenarioRule: ActivityScenarioRule<RecyclerViewUiTestActivity> =
        ActivityScenarioRule(RecyclerViewUiTestActivity::class.java)

    companion object {
        const val ITEM_BELOW_THE_FOLD = 5;
    }

    @Test
    fun recyclerView_isDisplayed() {
        onView(withId(R.id.rv_test)).check(matches(isDisplayed()))
    }

    @Test
    fun itemWithText_doesNotExist() {
        onView(withId(R.id.rv_test))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(ITEM_BELOW_THE_FOLD, click()))

        val itemText = "글자 5"
        onView(withText(itemText)).check(matches(isDisplayed()))
    }

    @Test
    fun firstAndLastItemsInRecyclerView_checkAllDisplayed() {
        var itemCount = 0
        activityScenarioRule.scenario.onActivity { activity ->
            val recyclerView: RecyclerView = activity.findViewById(R.id.rv_test)
            itemCount = recyclerView.adapter?.itemCount ?: 0
        }

        if (itemCount > 0) {
            onView(withId(R.id.rv_test)).perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(0))
            onView(withId(R.id.rv_test)).check(matches(atPosition(0, isDisplayed())))

            onView(withId(R.id.rv_test)).perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(itemCount - 1))
            onView(withId(R.id.rv_test)).check(matches(atPosition(itemCount - 1, isDisplayed())))
        }
    }

    private 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 viewHolder = (view as RecyclerView).findViewHolderForAdapterPosition(position)
                return itemMatcher.matches(viewHolder?.itemView)
            }
        }
    }
}

 

보다시피 이 테스트 파일에선 총 3가지의 테스트를 수행한다.

 

  1. 리사이클러뷰가 화면에 표시되는지 확인
  2. 5번 인덱스의 리사이클러뷰 아이템이 "글자 5"인지 확인
  3. 첫 아이템, 마지막 아이템이 화면에 표시되는지 확인

 

리사이클러뷰가 표시되는지 확인하는 것과 리사이클러뷰 안의 모든 아이템들이 표시되는지 확인하는 건 엄연히 다르다.

그 외에는 RecyclerViewActions 클래스를 통해 여러 함수들을 호출해서 클릭 액션과 스크롤 액션을 검증하는 테스트 코드들이기 때문에 넘기고, atPosition()을 짚고 넘어간다.

 

atPosition()은 리사이클러뷰 안의 특정 위치에 있는 아이템이 2번째 매개변수(itemMatcher)와 일치하는지 검증하기 위한 Matcher<View>를 리턴하는 함수다.

position은 당연히 리사이클러뷰 안에서 검증하고자 하는 아이템의 위치고, itemMatcher로는 isDiaplayed(), hasFocus() 등을 넣을 수 있다. 함수들의 종류는 ViewMatchers 클래스를 통해 호출할 수 있으니 아래 공식문서를 참고한다.

 

https://developer.android.com/reference/androidx/test/espresso/matcher/ViewMatchers

 

ViewMatchers  |  Android Developers

Stay organized with collections Save and categorize content based on your preferences. ViewMatchers public final class ViewMatchers A collection of hamcrest matchers that match Views. Summary Nested types Enumerates the possible list of values for getVisib

developer.android.com

 

0번 인덱스와 마지막 인덱스에 각각 아이템이 표시되는지 확인하기 위해 isDiaplayed()만 사용하고 있지만, 다른 ViewMatchers 함수를 넣어서 다른 내용을 검증하는 함수를 만들 수도 있다. 테스트 시나리오를 작성한 다음 공식문서에서 어떤 함수를 쓰면 좋을지 찾아보며 구현해 나가면 될 것이다.

반응형
Comments