구독과 좋아요
캐시미션의 UI Testing
쌓이는 버그 태스크 카드를 보며 UI 테스트 코드의 필요성을 실감하게 되었습니다😢

캐시미션의 UI Testing

안홍범 | 셀렉트스타 개발팀 Android Client

🔑 10분 안에 이런 걸 얻을 수 있어요.

  • 셀렉트스타 개발팀이 말하는 상세한 UI Test code 작성 방식
  • 셀렉트스타 개발팀이 효율적으로 UI Test를 진행하는 과정

캐시미션 앱(Android)에서는 어떻게 UI Test code를 작성할까

안녕하세요! 저는 셀렉트스타에서 캐시미션 Android 앱을 개발하고 있는 안홍범이라고 합니다👋🏻

모바일 앱 특성 상 유저에게서 직접적으로 인터랙션이 발생하는 만큼, 앱 개발 시 UI 테스트에 대한 고민을 빼놓을 수 없을 것입니다. 하지만 이는 분명히 꽤 중요한 부분임에도 불구하고 밀려있는 개발 업무를 처리하기에 바빠서 UI 테스트에는 거의 신경을 못 쓰고 있었습니다.

UI 테스트가 없었기 때문에 기존에 있는 UI를 수정하거나 새로운 UI를 추가할 때 아래와 같은 버그들을 마주하게 됩니다.

  • 이 버튼 클릭하면 다음으로 넘어가야하는데 갑자기 넘어가지질 않는데요?
  • 예전에 잘 보이던 에러 텍스트가 보이지 않아요!

저는 쌓이는 버그 태스크 카드를 보며 UI 테스트 코드의 필요성을 실감하게 되었습니다😢

👀 테스트 코드 범위

테스트 코드를 작성하기 전, 어디부터 어디까지 작성할 건지에 대한 기준이 필요합니다. UI에 대한 모든 부분을 테스트하는 것은 시간도 굉장히 많이 걸릴 뿐더러 의미가 없는 테스트 코드가 존재할 수 있기 때문입니다. 그러므로 저희 팀에서는 아래와 같은 범위로 UI 테스트 코드 범위를 한정지었습니다.

 

  1. 유저에 의해 데이터가 변경될 수 있는 View에 대한 테스트 코드를 작성한다.
  2. 인터랙션으로 인한 UI 변화에 따른 테스트 코드를 작성한다.
  3. Network, Local에서 오는 Data에 대한 UI 테스트 코드를 작성한다.

 

유저에 의해 데이터가 변경될 수 있는 View는 EditText 같은 데이터를 들고 있는 View가 해당될 것입니다. 이 데이터는 결국 어딘가로 전달되기 때문에 이 부분은 필수적으로 테스트가 필요하다고 판단했습니다.

모바일 화면에서 인터랙션은 빈번하기 때문에 당연하게도 유저의 인터랙션에 의한 UI의 변화는 반드시 테스트 되어야할 것입니다.

마지막으로 Network나 Local같은 UI 외부에서 오는 데이터는 항상 동일하지 않기 때문에 이 데이터에 대한 테스트 코드가 작성되어야 외부의 변화에 따른 테스트를 시도해 볼 수 있을 것입니다.

테스트 코드의 범위를 추려냈다면 다음으로 어떤 테스트 라이브러리를 사용하여 테스트를 작성할 것인지 결정해야 합니다.

✨ 테스트 라이브러리

기본적으로 Android에서 UI 테스트 코드를 작성한다고 하면, 가장 많이 쓰이는 테스트 라이브러리는 Espresso일 것입니다. 프로젝트를 처음 만들면 기본적으로 gradle의 dependencies에 추가되어 있기도 하고, Android Developer에서도 권장하는 라이브러리이기 때문입니다.

 

EspressoView에 대한 인터랙션, 상태 같은 값들을 테스트 할 수 있도록 도와주는 라이브러리입니다. Android Developer 가이드 문서에 따르면 Espresso를 사용하면 간결하고 아름답고 신뢰할 수 있는 UI 테스트를 작성할 수 있다고 합니다.

 

@Test
fun greeterSaysHello() {
	onView(withId(R.id.name_field)).perform(typeText("CashMission"))
	onView(withId(R.id.greet_button)).perform(click())
	onView(withText("Hello CashMission!")).check(matches(isDisplayed()))
}

 

하지만 Espresso만으로는 위에서 정한 테스트 코드 범위를 모두 커버할 수가 없습니다. 캐시미션 앱은 MVVM 아키텍쳐를 기반으로 구성되었기 때문에 UI 테스트 코드를 작성하기 위해서는 ViewModelRepository에 대한 가짜 객체가 필요합니다. 이 문제를 해결하기 위해 Mocking 라이브러리를 찾아보았습니다.  MockK : https://mockk.io/

여러 라이브러리를 둘러보던 중, 저희는 MockK라는 라이브러리를 사용하기로 결정했는데 이 라이브러리가 사용성도 좋아보였고 코루틴에 대한 지원도 활발하다고 판단하여 MockK를 선택하였습니다.

⚡️ UI Testing

캐시미션 서비스에서 유저분들이 가장 좋아하실만한 부분인 출금 신청 부분에 대한 테스트 코드를 살짝 보여드리겠습니다. 출금 신청 단계는 몇 가지가 존재하는데, 가장 첫 부분인 얼마를 출금하는지 묻는 부분의 UI 테스트 요구사항은 아래와 같았습니다.

 

출금 신청 페이지

1. 첫 진입 시 유저가 출금할 수 있는 금액이 타이틀에 잘 표시되는가?

2. 첫 진입 시 ‘다음’ 버튼이 비활성화되어 있는가?

3. 유저가 2,000원 미만의 금액을 입력했을 때 해당하는 에러 텍스트가 표시되는가? & ‘다음‘ 버튼이 여전히 비활성화 되어 있는가?

4. 유저가 2,000원 이상이지만 10원 단위가 아닌 금액을 입력했을 때 해당하는 에러 텍스트가 표시되는가? & ‘다음’ 버튼이 여전히 비활성화 되어 있는가?

5. 유저가 출금할 수 있는 금액보다 더 많은 금액을 입력했을 때 해당하는 에러 텍스트가 표시되는가? & ‘다음’ 버튼이 여전히 비활성화 되어 있는가?

6. 10원 단위로 2,000원 이상 올바른 금액을 입력했을 때 에러 텍스트가 보이지 않고 ‘다음’ 버튼이 활성화 되어 있는가?

Android에서 Espresso를 통해 UI 테스트를 작성하고 실행할 경우, 비정상적인 작동을 피하기 위해서 에뮬레이터 혹은 기기의 개발자 옵션의 그림 – 창 애니메이션 배율, 전환 애니메이션 배율, Animator 길이 배율을 모두 사용 안 함으로 변경한 뒤 진행하는 것을 권장하고 있습니다.

Initializing

테스트 코드를 작성하기 위해 필요한 것들을 준비해보겠습니다. 위 요구사항을 충족하는 테스트 코드를 작성하기 위해서는 ViewModel도 필요하고, 필요한 데이터를 가져오는 Repository도 필요합니다.

캐시미션 앱은 Dagger Hilt를 사용하여 의존성을 관리하고 있기 때문에 Hilt Test 라이브러리를 함께 사용하여 테스트 코드를 작성했습니다.

 

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ExchangeRequestHowMuchFragmentTest {

	// 리소스에 접근하기 위한 변수
	private val resources: Resources = InstrumentationRegistry
		.getInstrumentation()
		.targetContext
		.resources
	
	// 테스트 시 Hilt 컴포넌트를 생성하기 위해 Rule을 추가해줍니다
	@get:Rule
	val hiltRule by lazy { HiltAndroidRule(this) }
	
	// mocking 객체
	private val exchangeRepository: ExchangeRepository = mockk()
	@BindValue
	val viewModel: ExchangeRequestViewModel = spyk(
		ExchangeRequestViewModel(exchangeRepository)
	)

}

 

Hilt Test 라이브러리에서 제공하는 @BindValue 어노테이션을 ViewModel에 달아줌으로써 테스트에서 사용하는 ViewModel을 간단하게 spy 객체로 바꿔치기 할 수 있습니다.

 

@Before
fun setUp() {
	// getExchangeInfo, getBankCodeList는 suspend 함수이기 때문에 coEvery 사용
	// 테스트 실행 전 언제나 제대로 된 데이터를 가져오도록 지정
	coEvery { exchangeRepository.getExchangeInfo() } returns Result.Success(exchangeInfo)
	coEvery { exchangeRepository.getBankCodeList() } returns Result.Success(bankCodeList)
	
	launchFragmentInHiltContainer<ExchangeRequestBottomSheetDialogFragment>()
}

@After
fun tearDown() {
	clearAllMocks()
}

 

물론 respository에도 @BindValue 어노테이션을 달고 ExchangeRepository의 가짜 구현체를 만들어서 바꿔치기하여 사용하거나 @Inject 어노테이션을 달고 실제 객체 값으로 테스트할 수도 있으나, 이 UI 테스트에 서는 ExchangeRepository의 모든 API가 필요하지 않기 때문에 mockk() 메서드를 이용해서 Mock 객체를 사용했습니다.

 

외부에서 데이터를 가져오는 역할인 ExchangeRepository는 언제나 성공적인 데이터를 리턴하도록 가정해주었기 때문에 본문에는 작성되어 있지 않지만 실패적인 데이터에 대한 테스트 코드도 작성되어야 할 것입니다.

 

테스트 하고자 하는 ExchangeRequestBottomSheetDialogFragment@AndroidEntryPoint 어노테이션이 달려있는 프래그먼트이기 때문에 Android Developer에서 권장하는 방법인 launchFragmentInHiltContainer 메소드를 통해 프래그먼트를 Hilt 테스트 액티비티에 붙여서 실행시켰습니다.

inline fun <reified T : Fragment> launchFragmentInHiltContainer(
	fragmentArgs: Bundle? = null,
	@StyleRes themeResId: Int = R.style.FragmentScenarioEmptyFragmentActivityTheme,
	crossinline action: Fragment.() -> Unit = {}
) {
	val startActivityIntent = Intent.makeMainActivity(
		ComponentName(
			ApplicationProvider.getApplicationContext(),
			HiltTestActivity::class.java
		)
	).putExtra(
		"androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY",
		themeResId
	)
	
	ActivityScenario.launch<HiltTestActivity>(startActivityIntent).onActivity { activity ->
		val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate(
		  Preconditions.checkNotNull(T::class.java.classLoader),
	    T::class.java.name
		)
	  fragment.arguments = fragmentArgs
	
	  activity.supportFragmentManager
		  .beginTransaction()
	    .add(android.R.id.content, fragment, "")
	    .commitNow()
		fragment.action()
	}
}

 

아직 이 테스트를 진행하기 위한 몇 가지 준비사항이 남아있습니다. Hilt를 사용하는 테스트이기 때문에 HiltTestApplication을 사용하는 커스텀 Test Runner를 만들고 등록해야합니다.

 

build.gradle

android {
    ...
    defaultConfig {
        ...
        // 패키지 이름에는 사연이 담겨있습니다
        testInstrumentationRunner "com.selectstar.hwshin.cachemission.HiltTestRunner"
    }
}

Testing 1

자 이제 UI 테스트를 위한 모든 준비가 끝났습니다🙌

이제 본격적으로 테스트 코드를 작성해보겠습니다. 아래 코드는 첫 번째 요구사항을 충족하는 테스트 코드입니다. 유저가 출금 신청 화면에 첫 진입했을 경우 타이틀에 현재 얼마가 있고, 얼마를 출금할 것인지를 묻는 텍스트가 표시되어야 합니다. 테스트 코드 범위 중 3번째였던 외부 데이터에 대한 UI 테스트를 만족시키는 테스트가 될 수 있겠네요.

 

@Test
fun givenExchangeInfoWhenStartFragmentThenShowCorrectPoint() {
	onView(withId(R.id.textView_exchange_request_how_much_title))
		.check(
			matches(
				withText(
					resources.getString(
						R.string.text_exchange_request_how_title,
						exchangeInfo.currentPoint.formatPlusWon()
					)
				)
			)
		)
}

이미지에서 굉장히 빠르게 UI가 표시되었다가 사라지기 때문에 테스트가 진행된게 맞나? 라는 생각이 들 수도 있는데요, Run탭의 Window를 확인해보면 테스트가 성공적으로 완료된 것을 확인할 수 있습니다🎉

Testing 2

이번엔 3번째 요구사항에 대한 테스트 코드를 살펴보겠습니다.

유저가 2000원 미만의 금액을 입력하면 빨간색 에러 텍스트가 표시되고 다음으로 넘어가는 버튼은 여전히 비활성화되어 있어야 합니다. 이는 테스트 코드 범위 중 1, 2번을 모두 만족시키는 테스트 코드가 될 것입니다.

 

@Test
fun givenExchangeInfoWhenWriteAtLeast2000PointThenShowErrorAndDisableNextButton() {
	// when
	onView(withId(R.id.cashMissionEditText_exchange_request_how_much))
		.perform(
			CashMissionEditTextViewActions().setText("1999")
		)

	// then
	onView(withId(R.id.cashMissionEditText_exchange_request_how_much))
		.check(
			matches(
	      CashMissionEditTextViewMatchers().isDisplayErrorAndHint(
					resources.getString(R.string.text_exchange_request_how_in_correct_hint),
          true
        )
			)
		)

	onView(withId(R.id.cashmissionButton_exchange_request_how_much))
		.check(matches(not(isEnabled())))
}

 

여기서 살펴볼만한 부분이 2가지 있습니다. 우선 첫 번째는 CashMissionEditTextViewActions().writeText()인데요, cashMissionEditText_exchange_request_how_much는 이름에서 추측할 수 있듯 이 뷰는 캐시미션의 여러 곳에서 사용되는 커스텀 뷰입니다. 그런데 이 커스텀 뷰는 EditText를 상속받는 뷰가 아닌 ViewGroup에 EditText와 여러가지 뷰가 혼합되어 이루어진 커스텀 뷰입니다. 이 커스텀 뷰에 대한 어떤 액션을 명령해주기 위해서는 커스텀 ViewAction이 필요합니다.

 

class CashMissionEditTextViewActions {
	
	fun setText(text: CharSequence?): ViewAction {
		return object : ViewAction {
		  override fun getConstraints(): Matcher<View> {
			   return CoreMatchers.allOf(
		       ViewMatchers.isAssignableFrom(CashMissionEditText::class.java),
	         ViewMatchers.isDisplayed()
	       )
			}
	
			override fun getDescription(): String {
		    return "update new text"
			}
	
			override fun perform(uiController: UiController?, view: View?) {
		    val editText = view as? CashMissionEditText
	      editText?.onEditText {
		      setText(text ?: "")
				}
			}
		}
	}

}

 

CashMissionEditText에 속하는 ‘진짜’ EditText에 접근할 수 있는 onEditText 함수를 통해 파라미터 값인 text를 사용하여 새로운 텍스트를 작성하는 액션 클래스를 만들어 주었습니다.

ViewAction에 대한 자세한 설명은 링크를 참고해보세요.

두 번째로 눈여겨볼만 한 부분은 then 부분에 있는 isDisplayErrorAndHint 메소드입니다. ViewAction과 마찬가지 이유로 커스텀 BoundedMatcher를 만들어야 합니다.

참고 https://developer.android.com/reference/androidx/test/espresso/matcher/BoundedMatcher

 

CashMissionEditTextViewMatchers().isDisplayErrorAndHint(
    resources.getString(R.string.text_exchange_request_how_in_correct_hint),
  true
)
class CashMissionEditTextViewMatchers {

	fun isDisplayErrorAndHint(
		text: String = "",
		isDisplay: Boolean
	): BoundedMatcher<View, CashMissionEditText> {
    return CashMissionEditTextViewBoundedMatcher.WithErrorAndHintMatcher(
			text, 
			isDisplay
		)
	}

	sealed class CashMissionEditTextViewBoundedMatcher :
		BoundedMatcher<View, CashMissionEditText>(CashMissionEditText::class.java) {
		
		class WithErrorAndHintMatcher(
            private val hintText: String?,
            private val isDisplay: Boolean
		) : CashMissionEditTextViewBoundedMatcher() {
	    
			override fun describeTo(description: Description?) {
	      description?.run {
	        appendText("Checking the matcher on received view: ")
          appendText("display error hint text = $hintText")
        }
      }

			override fun matchesSafely(item: CashMissionEditText?): Boolean {
		     val isValidHint = if (hintText.isNullOrEmpty()) {
	         item?.hint.isNullOrEmpty()
         } else {
		       item?.hint == hintText
         }
         return isValidHint && item?.isError == isDisplay
      }
    }
		
	}
}

 

 

CashMissionEditTextViewMatchers 클래스 내부에 WithErrorAndHintMatcher라는 이름으로 커스텀 BoundedMatcher를 구현해주고 isDisplayErrorAndHint 메서드에서 이 Matcher를 리턴해주도록 했습니다.

 

이 테스트도 실행해보겠습니다🎈

 

 

 

 

Espresso에서 사용할 수 있는 전체 ActionMatcher들은 아래 링크에서 확인해보실 수 있습니다.

참조 https://developer.android.com/training/testing/espresso/cheat-sheet?hl=ko

🌿 끝맺음

UI Test 코드를 작성함으로써 캐시미션 앱은 UI 코드 작성에 대한 안정성을 확보할 수 있게 되었습니다. 하지만 Android UI Test 작성에는 고려해야할 사항이 많이 남아있습니다. View의 특성으로 인한 유휴 상태도 고려해야하고 다양한 View의 검증 방법도 고민해봐야 합니다.

그리고 UI Test는 테스트 시간이 꽤 오래 걸립니다. Unit Test와는 달리 거의 빌드 시간 만큼의 시간이 소요됩니다. 캐시미션 안드로이드 팀에선 UI Test를 최대한 커버할 수 있는 Unit Test 작성을 위한 방법에 대해 다양한 방식을 고민해보고 있습니다.

UI 테스트 코드를 작성하고 나니, 새로운 UI 코드를 작성할 때 조심스럽게 코드를 수정하던 기존과는 다르게 안전하고 빠르게 작성할 수 있게 되었습니다. 테스트 코드를 작성하는 것은 매우 귀찮고 시간이 오래 걸리는 일이지만 잘 짜놓은 테스트 코드가 있다면 개발 속도는 점점 빨라질 것이라고 생각하고 있습니다.

가까운 시일에 캐시미션의 모든 영역에 테스트 코드가 작성되어 있기를 기대해 보며 이만 글을 마치겠습니다!

읽어주셔서 감사합니다🙌

인공지능의 새로운 성장과 발전을 경험하세요.

셀렉트스타의 여정에 함께 하고 싶나요?

Related Posts