Testing in Jetpack Compose
2. What to test?
테스트 해볼 것들
- Test that the tabs show the intended icon and text
- Test that the animation matches the spec
- Test that the triggered navigation events are correct
- Test the placement and distances of the UI elements in different states
- Take a screenshot of the bar and compare it with a previous screenshot
3. Create a simple UI test
Create the TopAppBarTest file
Compose는 createComposeRule()을 호출하여 얻을 수 있는 ComposeTestRule과 함께 제공됩니다. 이 규칙을 사용하면 테스트 중인 Compose 콘텐츠를 설정하고 상호 작용할 수 있습니다.
Add the ComposeTestRule
package com.example.compose.rally
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
// TODO: Add tests
}
Testing in isolation
createAndroidComposeRule
을 통해서 앱의 메인 액티비티를 실행하듯 테스틀 수행할 수 있다.
// Don't copy this over
@get:Rule
val composeTestRule = createAndroidComposeRule(RallyActivity::class.java)
하지만 컴포즈 에서는 더 간단하게 할 수도 있다. setContent 를 사용하면 된다.
// Don't copy this over
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun myTest() {
composeTestRule.setContent {
Text("You can set any Compose content!")
}
}
}
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.compose.rally.ui.components.RallyTopAppBar
import org.junit.Rule
import org.junit.Test
class TopAppBarTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rallyTopAppBarTest() {
composeTestRule.setContent {
RallyTopAppBar(
allScreens = ,
onTabSelected = { /*TODO*/ },
currentScreen =
)
}
}
}
The importance of a testable Composable
@Test
fun rallyTopAppBarTest() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
Thread.sleep(5000)
}
- 테스트는 개별로 실행 가능
- sleep 로 동작을 볼 수 있게 대기한다.
- 테마 적용도 자유롭게 해도 상관 없다.
Verify that the tab is selected
composeTestRule{.finder}{.assertion}{.action}
- 요런 식으로 테스트를 짜게 되낟.
- 치트 시트
- 문서
- example:
onNodeWithText
,onNodeWithContentDescription
,isSelected
,hasContentDescription
,assertIsSelected
@Test
fun rallyTopAppBarTest_currentTabSelected() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithContentDescription(RallyScreen.Accounts.name)
.assertIsSelected()
}
4. Debugging tests
import androidx.compose.ui.test.onNodeWithText
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithText(RallyScreen.Accounts.name.uppercase())
.assertExists()
}
- 탭이 선택되고, 이 텍스트가 존재하는 지 살피면 될까?
- 하지만 테스트는 실패한다,..
- 테스트를 시멘틱 트리를 통해 디버깅하는 방법을 배워보자.
Semantics tree
- 접근성에서 사용하는 방법 처럼(톡백) 요소들의 트리를 볼 수 있다.
- 경고: Semantics 속성에 대한 Layout Inspector 지원은 아직 사용할 수 없습니다.
printToLog
로 출력할 수 있다.
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.printToLog
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule.onRoot().printToLog("currentLabelExists")
composeTestRule
.onNodeWithText(RallyScreen.Accounts.name.uppercase(Locale.getDefault()))
.assertExists() // Still fails
}
...com.example.compose.rally D/currentLabelExists: printToLog:
Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
|-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
[SelectableGroup]
MergeDescendants = 'true'
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
Role = 'Tab'
Selected = 'false'
StateDescription = 'Not selected'
ContentDescription = 'Bills'
Actions = [OnClick]
MergeDescendants = 'true'
ClearAndSetSemantics = 'true'
- 주의 : 컴포즈에는 ID가 없으므로 트리에서 매칭되는 아이디는 테스트에 사용할 수 없다.
- 대신 hasTestTag, testTag 로 사용 가능하다.
- 대문자 Accounts 가 없기 때문에 당연히 테스트는 실패하는 것이다.
private fun RallyTab(text: String...)
...
Modifier
.clearAndSetSemantics { contentDescription = text }
- 탭 컴포저블에서 contentDescription 를 설정하므로, 트리에서 나타나는 것.
- 이 수정자는 하위 항목에서 속성을 지우고 자체 콘텐츠 설명을 설정하므로 "ACCOUNTS"가 아닌 "Accounts"가 표시됩니다.
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNodeWithContentDescription(RallyScreen.Accounts.name)
.assertExists()
}
- 이제 테스트가 통과합니다. 하지만 별 도움이 안됨. 모든 트리에 name 이 그대로 노출되기에 선택 여부를 테스트하는 것이 아니기 때문임
5. Merged and unmerged Semantics trees
시맨틱 트리는 항상 가능한 한 간결하게 하려고 노력하여 관련 정보만 표시합니다. 예를 들어 TopAppBar에서는 아이콘과 레이블이 서로 다른 노드일 필요가 없습니다. "개요" 노드를 살펴보십시오.
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
예를 들어 Text 컴포저블이 포함된 버튼을 나타낼 수 있습니다. MergeDescendants = 'true' 속성은 이 노드에 자손이 있지만 병합되었음을 알려줍니다. 테스트에서 우리는 종종 모든 노드에 액세스해야 합니다.
탭 내부의 Text가 표시되는지 여부를 확인하기 위해 onRoot 파인더에 useUnmergedTree = true를 전달하는 병합되지 않은 Semantics 트리를 쿼리할 수 있습니다.
Printing with useUnmergedTree = 'true'
Node #1 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
|-Node #2 at (l=0.0, t=63.0, r=1080.0, b=210.0)px
[SelectableGroup]
MergeDescendants = 'true'
|-Node #3 at (l=42.0, t=105.0, r=105.0, b=168.0)px
| Role = 'Tab'
| Selected = 'false'
| StateDescription = 'Not selected'
| ContentDescription = 'Overview'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| ClearAndSetSemantics = 'true'
| |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
| Text = 'ACCOUNTS'
| Actions = [GetTextLayoutResult]
|-Node #11 at (l=552.0, t=105.0, r=615.0, b=168.0)px
Role = 'Tab'
Selected = 'false'
StateDescription = 'Not selected'
ContentDescription = 'Bills'
Actions = [OnClick]
MergeDescendants = 'true'
ClearAndSetSemantics = 'true'
|-Node #6 at (l=189.0, t=105.0, r=468.0, b=168.0)px
| Role = 'Tab'
| Selected = 'true'
| StateDescription = 'Selected'
| ContentDescription = 'Accounts'
| Actions = [OnClick]
| MergeDescendants = 'true'
| |-Node #9 at (l=284.0, t=105.0, r=468.0, b=154.0)px
| Text = 'ACCOUNTS'
| Actions = [GetTextLayoutResult]
ACCOUNTS 를 갖는 하위 노드가 생겼다!
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasText
...
@Test
fun rallyTopAppBarTest_currentLabelExists() {
val allScreens = RallyScreen.values().toList()
composeTestRule.setContent {
RallyTopAppBar(
allScreens = allScreens,
onTabSelected = { },
currentScreen = RallyScreen.Accounts
)
}
composeTestRule
.onNode(
hasText(RallyScreen.Accounts.name.uppercase()) and
hasParent(
hasContentDescription(RallyScreen.Accounts.name)
),
useUnmergedTree = true
)
.assertExists()
}
- 대문자 Accounts 를 갖는 노드 이면서 부모가 Accounts 인 노드가 있음을 테스트한다.
6. Synchronization
- 애니메이션이 있는 경우
package com.example.compose.rally
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.example.compose.rally.ui.overview.OverviewBody
import org.junit.Rule
import org.junit.Test
class OverviewScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@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가 영구적으로 사용 중이므로 앱을 테스트와 동기화할 방법이 없음을 알려줍니다.
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 애니메이션을 다시 시작하는 대신 무한 애니메이션을 사용할 수 있습니다.
- 무한 애니메이션은 Compose 테스트가 이해하는 특별한 경우이므로 테스트를 계속 바쁘게 만들지 않을 것입니다.
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateValue
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.ui.unit.Dp
...
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) {
- 컴포저블 구현 부에서 infiniteRepeatable 를 사용하도록 수정하면 테스트가 성공한다.
7. Optional exercise
이 단계에서는 작업(치트 시트 테스트 참조)을 사용하여 RallyTopAppBar의 다른 탭을 클릭하면 선택 항목이 변경되는지 확인합니다.
package com.example.compose.rally
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
class RallyAppTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rallyAppTest() {
composeTestRule.setContent {
RallyApp()
}
Thread.sleep(1000)
composeTestRule.onRoot().printToLog("currentLabelExists")
composeTestRule
.onNode(
hasContentDescription("Accounts"),
useUnmergedTree = true
).performClick()
Thread.sleep(1000)
composeTestRule
.onNode(
hasContentDescription("Accounts") and isSelected(),
useUnmergedTree = true
)
.assertExists()
Thread.sleep(1000)
}
}
- homework 성공
Reference
2022.12.19 - [Android/Jetpack Compose] - [Jetpack Compose] 컴포즈에서 Navigation 사용하는 방법 정리
2022.12.18 - [Android/Jetpack Compose] - [Jetpack Compose] 상태 및 사이드이팩트 고급 내용 정리 (State, SideEffects)
2022.12.17 - [Android/Jetpack Compose] - [Jetpack Compose] 애니메이션 활용 및 기본 내용 정리