본문 바로가기
Android

[Android-Test] Android Compose Test 도입하기 - 실전

by 너츠너츠 2023. 9. 27.

배경

이번 포스팅에선 CodeLab에서 제공하는 Compose Test를 기준으로 정리해보려고 합니다.

테스트를 따라가기에 앞서 컴포저블 함수들이 어떻게 이뤄져있는지 파악해보겠습니다.

 

Rally의 구조

Rally 앱은 RallyTopAppBar와 현재 screen의 content로 구성되어 있으며 AppBar에서 탭을 클릭 시 해당 화면으로 변경됩니다. 

CodeLab 테스트에선 OverviewBody에 대한 테스트를 진행하므로 Overview 화면을 기준으로 표시했습니다.

 

간단한 UI 테스트 만들기

CodeLab에선 Account bar가 잘 클릭되는지 테스트를 진행합니다.

@Test
fun rallyTopAppBarTest() {
   val allScreens = RallyScreen.values().toList()
   composeTestRule.setContent {
      RallyTopAppBar(
         allScreens = allScreens,
         onTabSelected = { /*TODO*/ },
         currentScreen = RallyScreen.Accounts
      )
   }

   composeTestRule
      .onNodeWithContentDescription(RallyScreen.Accounts.name)
      .assertIsSelected()
}

 

여기서 드는 의문점은 .onNodeWithContentDescription으로 RallyScreen.Accounts.name에 대한 컴포넌트를 찾는 과정입니다.

onNodeWithContentDescription은 주어진 label을 기준으로 semantics node를 검색합니다.

 

저희가 ContentDescription 명시한 것도 아닌데 어떻게 해당 컴포넌트를 찾을 수 있었을까요??

출력 로그에 따라 생각해보자면 allScreens 항목에 RallyScreen 리스트를 넣어줬으므로 enum class name이 각 컴포넌트의 ContentDescription로서 사용된 것으로 생각됩니다.

 

병합 및 병합 해제 시멘틱 트리

이번 테스트에선 탭의 text와 동일한 컴포넌트를 찾는 테스트를 진행합니다.

가벼운 마음으로 Text가 포함되어 있는 노드를 찾으면 되겠지? 라는 생각으로 코드를 작성하면 "실패"를 보실 수 있습니다.

composeTestRule
   .onNode(hasText(RallyScreen.Accounts.name.uppercase()))
   .assertExists()

 

이게 왜 실패야?? 라고 생각하실 수 있지만 시멘틱 트리를 출력했던 위의 로그를 보시면 Text에 대한 내용이 포함되어 있지 않습니다.

Text까지 포함되서 파악되게 하려면 useUnmergedTree 속성을 통해 하위 요소까지 병합되도록 만들어줘야 합니다.

composeTestRule
   .onNode(
      hasText(RallyScreen.Accounts.name.uppercase()),
      useUnmergedTree = true
   )
   .assertExists()

 

동기화

알림 카드에 적용된 반복해서 깜박이는 애니메이션으로 이 요소에 주목하게 됩니다.

@Test
fun overviewScreen_alertsDisplayed() {
   composeTestRule.setContent {
      OverviewBody()
   }

   composeTestRule
      .onNodeWithText("Alerts")
      .assertIsDisplayed()
}

이 테스트는 실패하게 되는데 이유는 다음과 같습니다.

androidx.compose.ui.test.junit4.android.ComposeNotIdleException: Idling resource timed out: possibly due to compose being busy.IdlingResourceRegistry has the following idling resources registered:- [busy] androidx.compose.ui.test.junit4.android.ComposeIdlingResource@d075f91

기본적으로 Compose가 영구적으로 사용 중이므로 앱을 테스트와 동기화할 방법이 없다는 의미입니다.

이미 짐작했겠지만 문제는 무한으로 깜박이는 애니메이션입니다. 앱이 유휴 상태가 아니므로 테스트를 계속할 수 없습니다.

무한 애니메이션의 구현을 살펴보겠습니다.

// OverviewBody.kt
var currentTargetElevation by remember {  mutableStateOf(1.dp) }
LaunchedEffect(Unit) {
    // Start the animation
    currentTargetElevation = 8.dp
}
val animatedElevation = animateDpAsState(
    targetValue = currentTargetElevation,
    animationSpec = tween(durationMillis = 500),
    finishedListener = {
        currentTargetElevation = if (currentTargetElevation > 4.dp) {
            1.dp
        } else {
            8.dp
        }
    }
)
Card(elevation = animatedElevation.value) { ... }

이 코드는 기본적으로 애니메이션이 완료(finishedListener)될 때까지 대기한 다음 다시 실행됩니다.

 

이 테스트를 수정하는 한 가지 방법은 개발자 옵션에서 애니메이션을 사용 중지하는 것입니다. 이 방법은 View 환경에서 문제 해결을 위해 널리 사용되는 방법의 하나입니다.

 

Compose에서는 애니메이션 API가 테스트 가능성을 염두에 두고 설계되었으므로 올바른 API를 사용하여 문제를 해결할 수 있습니다. animateDpAsState 애니메이션을 다시 시작하는 대신 무한 애니메이션을 사용할 수 있습니다.

val infiniteElevationAnimation = rememberInfiniteTransition()
val animatedElevation: Dp by infiniteElevationAnimation.animateValue(
   initialValue = 1.dp,
   targetValue = 8.dp,
   typeConverter = Dp.VectorConverter,
   animationSpec = infiniteRepeatable(
      animation = tween(500),
      repeatMode = RepeatMode.Reverse
   )
)
Card(elevation = animatedElevation) {

 

RallyTopAppBar의 다른 탭을 클릭하면 선택 항목이 변경되는지 확인하는 테스트 (연습)

CodeLab에서는 이 부분에 대한 해답을 제시하고 있지 않습니다. 그래서 초반부에 Rally가 어떻게 구성되어 있는지 먼저 파악한 것입니다.

 

처음에는 첫 화면을 Overview로 설정한 후 Accounts.name에 해당하는 노드를 찾아서 perfomClick()를 실행했을 때 테스트가 성공할꺼라 가정하고 구현했습니다.

val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
   RallyTopAppBar(
      allScreens = allScreens,
      onTabSelected = { },
      currentScreen = RallyScreen.Overview
   )
}

composeTestRule
   .onNodeWithContentDescription(RallyScreen.Accounts.name)
   .performClick()
   .assertIsSelected()

 

하지만 저에게 돌아온 테스트 답은 Fail이었습니다. 

 

코드상으로는 문제가 없는 것 같은데 왜 그럴까 하면서 시멘틱 트리를 출력해보면 초기 composeRule에 컴포저블을 붙였던 형태 그대로 남아있는 것을 확인할 수 있습니다.

이 테스트를 통해 perfomClick을 한다고 해도 시멘틱 트리에 변화를 주진 않는다는 것을 확인할 수 있었습니다.

 

그래서 저는 RallyApp에서 사용된 currentScreen의 값이 변화되는지에 초점을 맞추고 테스트를 진행했습니다.

기존 로직은 탭이 클릭되었을 때 currentScreen의 값이 변경되면서 리컴포지션이 발생 함으로 currentScreen의 값만 정상적으로 바뀐다면 화면이 변경될 것이라 생각했습니다.

@Test
fun rallyTOpAppBarTest_clickAnotherTab_selectionChanges() {
   val allScreens = RallyScreen.values().toList()
   var currentScreen = RallyScreen.Overview

   composeTestRule.setContent {
      Scaffold(
         topBar = {
            RallyTopAppBar(
               allScreens = allScreens,
               onTabSelected = { screen -> currentScreen = screen },
               currentScreen = currentScreen
            )
         }
      ) { innerPadding ->
         Box(Modifier.padding(innerPadding)) {
            currentScreen.content(onScreenChange = { screen -> currentScreen = screen })
         }
      }
   }

   composeTestRule
      .onNodeWithContentDescription(RallyScreen.Accounts.name)
      .performClick()

   assert(currentScreen == RallyScreen.Accounts)
}

결과는 성공적이었습니다.

 

마무리

테스트 연습을 마무리한 후 찰스님께서 올려주신 답안을 봤는데 기존 코드를 수정해서 테스트코드를 훨씬 줄이신 것을 보고 아직 멀었구나.. 라는 것을 깨달을 수 있었습니다 ㅎㅎ..

반응형

댓글