티스토리 뷰

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}
@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에서는 아이콘과 레이블이 서로 다른 노드일 필요가 없습니다. "개요" 노드를 살펴보십시오.

https://developer.android.com/static/codelabs/jetpack-compose-testing/img/d20c96207c30e44a_1920.png

|-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

https://developer.android.com/static/codelabs/jetpack-compose-testing/img/8c467af3570b8de6.gif

  • 애니메이션이 있는 경우
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

 

Testing in Jetpack Compose  |  Android Developers

In this codelab you’ll learn about testing UIs created with Jetpack Compose. You will write your first tests while learning about testing in isolation, debugging tests, semantics trees and synchronization.

developer.android.com

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] 애니메이션 활용 및 기본 내용 정리

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함