배경
이번 포스팅에선 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)
}
결과는 성공적이었습니다.
마무리
테스트 연습을 마무리한 후 찰스님께서 올려주신 답안을 봤는데 기존 코드를 수정해서 테스트코드를 훨씬 줄이신 것을 보고 아직 멀었구나.. 라는 것을 깨달을 수 있었습니다 ㅎㅎ..
'Android' 카테고리의 다른 글
[Android] UI 레이어 - 이벤트와 상태 (0) | 2023.09.29 |
---|---|
[Android] 앱 아키텍처 UI레이어와 UiState (0) | 2023.09.28 |
[Android-Test] Android Compose Test 도입하기 - 이론 (0) | 2023.09.26 |
[Android-Test] Android Test코드 작성하기5 - Hilt (0) | 2023.09.26 |
[Android-Test] Android Test코드 작성하기4 - Flow (0) | 2023.09.26 |
댓글