Compose 레이아웃 테스트

UI나 화면 테스트는 Compose 코드의 올바른 동작을 확인하는 데 사용되므로 개발 프로세스 초기에 오류를 포착하여 앱 품질을 개선할 수 있습니다.

Compose는 요소를 찾아 속성을 확인하고 사용자 작업을 실행하는 일련의 테스트 API를 제공합니다. 시간 조작과 같은 고급 기능도 포함되어 있습니다.

시맨틱

Compose의 UI 테스트는 시맨틱을 사용하여 UI 계층 구조와 상호작용합니다. 이름에서 알 수 있듯이 시맨틱은 UI 요소에 의미를 부여합니다. 이 컨텍스트에서 'UI 조각'(또는 요소)은 단일 컴포저블에서 전체 화면에 이르기까지의 어떤 것을 의미할 수 있습니다. 시맨틱 트리는 UI 계층 구조와 함께 생성되고 UI 계층 구조를 형성합니다.

일반적인 UI 레이아웃 및 이 레이아웃이 해당하는 시맨틱 트리에 매핑되는 방식을 보여주는 다이어그램

그림 1. 일반적인 UI 계층 구조 및 시맨틱 트리

시맨틱 프레임워크는 주로 접근성에 사용되므로 테스트는 시맨틱에 의해 노출되는 UI 계층 구조 관련 정보를 활용합니다. 개발자는 노출할 내용과 양을 결정합니다.

그래픽 및 텍스트가 포함된 버튼

그림 2. 아이콘 및 텍스트가 포함된 일반적인 버튼

예를 들어 아이콘과 텍스트 요소로 구성된 이 같은 버튼의 경우 기본 시맨틱 트리에는 '좋아요'라는 텍스트 라벨만 포함됩니다. Text와 같은 일부 컴포저블이 일부 속성을 이미 시맨틱 트리에 노출하기 때문입니다. Modifier를 사용하여 시맨틱 트리에 속성을 추가할 수 있습니다.

MyButton(
    modifier = Modifier.semantics { contentDescription = "Add to favorites" }
)

설정

이 섹션에서는 Compose 코드를 테스트할 수 있도록 모듈을 설정하는 방법을 설명합니다.

먼저, UI 테스트가 포함된 모듈의 build.gradle 파일에 다음 종속 항목을 추가합니다.

// Test rules and transitive dependencies:
androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
// Needed for createComposeRule, but not createAndroidComposeRule:
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

이 모듈에는 ComposeTestRule은 물론 AndroidComposeTestRule이라는 Android용 구현이 포함되어 있습니다. 이 규칙을 통해 Compose 콘텐츠를 설정하거나 활동에 액세스할 수 있습니다. Compose의 일반적인 UI 테스트는 다음과 같습니다.

// file: app/src/androidTest/java/com/package/MyComposeTest.kt

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    // use createAndroidComposeRule<YourActivity>() if you need access to
    // an activity

    @Test
    fun MyTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

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

테스트 API

요소와 상호작용하는 주요 방법에는 세 가지가 있습니다.

  • 파인더를 사용하면 요소(또는 시맨틱 트리의 노드)를 하나 이상 선택하여 어설션을 만들거나 작업을 실행할 수 있습니다.
  • 어설션은 요소가 있는지 또는 특정 속성을 보유하는지 확인하는 데 사용됩니다.
  • 작업은 클릭이나 기타 동작과 같은 시뮬레이션된 사용자 이벤트를 요소에 삽입합니다.

이러한 API의 일부는 시맨틱 트리에서 노드를 하나 이상 참조하는 SemanticsMatcher를 허용합니다.

파인더

onNodeonAllNodes를 사용하여 각각 노드를 하나나 여러 개 선택할 수 있지만 onNodeWithText, onNodeWithContentDescription 등 가장 일반적인 검색에 편의 파인더를 사용할 수도 있습니다. Compose 테스트 요약본에서 전체 목록을 찾아볼 수 있습니다.

단일 노드 선택

composeTestRule.onNode(<<SemanticsMatcher>>, useUnmergedTree = false): SemanticsNodeInteraction
// Example
composeTestRule
    .onNode(hasText("Button")) // Equivalent to onNodeWithText("Button")

여러 노드 선택

composeTestRule
    .onAllNodes(<<SemanticsMatcher>>): SemanticsNodeInteractionCollection
// Example
composeTestRule
    .onAllNodes(hasText("Button")) // Equivalent to onAllNodesWithText("Button")

병합되지 않은 트리 사용

일부 노드는 하위 요소의 시맨틱 정보를 병합합니다. 예를 들어 다음과 같이 텍스트 요소가 두 개 있는 버튼은 라벨을 병합합니다.

MyButton {
    Text("Hello")
    Text("World")
}

다음과 같이 테스트에서 printToLog()를 사용하여 시맨틱 트리를 표시할 수 있습니다.

composeTestRule.onRoot().printToLog("TAG")

이 코드는 다음 출력을 생성합니다.

Node #1 at (...)px
 |-Node #2 at (...)px
   Role = 'Button'
   Text = '[Hello, World]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'

병합되지 않은 트리가 될 요소의 노드를 일치시켜야 한다면 다음과 같이 useUnmergedTreetrue로 설정하면 됩니다.

composeTestRule.onRoot(useUnmergedTree = true).printToLog("TAG")

이 코드는 다음 출력을 생성합니다.

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = '[Hello]'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = '[World]'

useUnmergedTree 매개변수는 모든 파인더에서 사용할 수 있습니다. 예를 들어 여기서는 onNodeWithText 파인더에서 사용됩니다.

composeTestRule
    .onNodeWithText("World", useUnmergedTree = true).assertIsDisplayed()

어설션

하나 이상의 매처가 있는 파인더가 반환한 SemanticsNodeInteraction에서 assert()를 호출하여 어설션을 확인합니다.

// Single matcher:
composeTestRule
    .onNode(matcher)
    .assert(hasText("Button")) // hasText is a SemanticsMatcher

// Multiple matchers can use and / or
composeTestRule
    .onNode(matcher).assert(hasText("Button") or hasText("Button2"))

assertExists, assertIsDisplayed, assertTextEquals 등 가장 일반적인 어설션에 편의 함수를 사용할 수도 있습니다. Compose 테스트 요약본에서 전체 목록을 찾아볼 수 있습니다.

다음과 같이 노드 컬렉션에서 어설션을 확인하는 함수도 있습니다.

// Check number of matched nodes
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertCountEquals(4)
// At least one matches
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAny(hasTestTag("Drummer"))
// All of them match
composeTestRule
    .onAllNodesWithContentDescription("Beatle").assertAll(hasClickAction())

작업

노드에 작업을 삽입하려면 다음과 같이 perform…() 함수를 호출합니다.

composeTestRule.onNode(...).performClick()

다음은 몇 가지 작업의 예입니다.

performClick(),
performSemanticsAction(key),
performKeyPress(keyEvent),
performGesture { swipeLeft() }

Compose 테스트 요약본에서 전체 목록을 찾아볼 수 있습니다.

매처

이 섹션에서는 Compose 코드를 테스트하는 데 사용할 수 있는 몇 가지 매처에 관해 설명합니다.

계층적 매처

계층적 매처를 사용하면 시맨틱 트리에서 위 또는 아래로 이동하고 단순 일치를 실행할 수 있습니다.

fun hasParent(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnySibling(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyAncestor(matcher: SemanticsMatcher): SemanticsMatcher
fun hasAnyDescendant(matcher: SemanticsMatcher):  SemanticsMatcher

다음은 이러한 매처가 사용되는 몇 가지 예입니다.

composeTestRule.onNode(hasParent(hasText("Button")))
    .assertIsDisplayed()

선택기

테스트를 만드는 다른 방법은 일부 테스트를 더 읽기 쉽게 만들 수 있는 선택기를 사용하는 것입니다.

composeTestRule.onNode(hasTestTag("Players"))
    .onChildren()
    .filter(hasClickAction())
    .assertCountEquals(4)
    .onFirst()
    .assert(hasText("John"))

Compose 테스트 요약본에서 전체 목록을 찾아볼 수 있습니다.

동기화

Compose 테스트는 기본적으로 UI와 동기화됩니다. ComposeTestRule을 통해 어설션이나 작업을 호출하면 테스트가 미리 동기화되고 UI 트리가 유휴 상태가 될 때까지 기다립니다.

일반적으로 별도의 조치를 취할 필요가 없습니다. 하지만 몇 가지 극단적 사례에 관해 알아야 합니다.

테스트가 동기화되면 Compose 앱이 가상 클록을 사용하여 시간을 앞당깁니다. 즉 Compose 테스트가 실시간으로 실행되지 않으므로 최대한 빨리 통과할 수 있습니다.

하지만 테스트를 동기화하는 메서드를 사용하지 않는 경우 리컴포지션이 발생하지 않으며 UI가 일시중지된 것으로 표시됩니다.

@Test
fun counterTest() {
    val myCounter = mutableStateOf(0) // State that can cause recompositions
    var lastSeenValue = 0 // Used to track recompositions
    composeTestRule.setContent {
        Text(myCounter.value.toString())
        lastSeenValue = myCounter.value
    }
    myCounter.value = 1 // The state changes, but there is no recomposition

    // Fails because nothing triggered a recomposition
    assertTrue(lastSeenValue == 1)

    // Passes because the assertion triggers recomposition
    composeTestRule.onNodeWithText("1").assertExists()
}

또한 이 요구사항은 앱의 나머지 부분이 아니라 Compose 계층구조에만 적용됩니다.

자동 동기화 사용 중지

assertExists()와 같은 ComposeTestRule을 통해 어설션이나 작업을 호출하면 테스트가 Compose UI와 동기화됩니다. 경우에 따라 이 동기화를 중지하고 클록을 직접 제어해야 할 수도 있습니다. 예를 들어 UI가 계속 사용 중일 때 애니메이션의 정확한 스크린샷을 캡처하는 시간을 제어할 수 있습니다. 자동 동기화를 사용 중지하려면 mainClockautoAdvance 속성을 false로 설정하세요.

composeTestRule.mainClock.autoAdvance = false

일반적으로 그런 다음에 직접 시간을 앞당깁니다. advanceTimeByFrame()을 사용하여 정확히 한 프레임을 앞당기거나 advanceTimeBy()를 사용하여 특정 기간만큼 앞당길 수 있습니다.

composeTestRule.mainClock.advanceTimeByFrame()
composeTestRule.mainClock.advanceTimeBy(milliseconds)

유휴 리소스

Compose는 모든 작업과 어설션이 유휴 상태에서 실행되도록 테스트와 UI를 동기화하여 필요에 따라 기다리거나 클록을 앞당길 수 있습니다. 하지만 결과가 UI 상태에 영향을 미치는 일부 비동기 작업은 테스트가 인식하지 못하는 동안 백그라운드에서 실행할 수 있습니다.

테스트에서 이 유휴 리소스를 만들고 등록하여 테스트 중인 앱이 사용 중인지 아니면 유휴 상태인지 파악할 때 고려할 수 있습니다. 예를 들어 Espresso 또는 Compose와 동기화되지 않는 백그라운드 작업을 실행하는 경우 추가 유휴 리소스를 등록해야 하지 않으면 조치를 취할 필요가 없습니다.

이 API는 Espresso의 유휴 리소스와 매우 유사하며 테스트 대상이 유휴 상태인지 아니면 사용 중인지 나타냅니다. Compose 테스트 규칙을 사용하여 IdlingResource의 구현을 등록합니다.

composeTestRule.registerIdlingResource(idlingResource)
composeTestRule.unregisterIdlingResource(idlingResource)

수동 동기화

경우에 따라 Compose UI를 테스트의 다른 부분 또는 테스트 중인 앱과 동기화해야 합니다.

waitForIdle은 Compose가 유휴 상태가 될 때까지 기다리지만 autoAdvance 속성에 종속됩니다.

composeTestRule.mainClock.autoAdvance = true // default
composeTestRule.waitForIdle() // Advances the clock until Compose is idle

composeTestRule.mainClock.autoAdvance = false
composeTestRule.waitForIdle() // Only waits for Idling Resources to become idle

두 경우 모두 waitForIdle은 대기 중인 그리기 및 레이아웃 단계도 기다립니다.

또한 advanceTimeUntil()을 사용하여 특정 조건이 충족될 때까지 클록을 앞당길 수 있습니다.

composeTestRule.mainClock.advanceTimeUntil(timeoutMs) { condition }

지정된 조건은 클록의 영향을 받을 수 있는 상태를 확인해야 합니다(조건은 Compose의 상태만 확인함). Android의 측정 또는 그리기에 종속되는 모든 조건(즉 Compose 외부의 측정 또는 그리기)은 waitUntil()과 같이 더 일반적인 개념을 사용해야 합니다.

composeTestRule.waitUntil(timeoutMs) { condition }

일반적인 패턴

이 섹션에서는 Compose 테스트에서 볼 수 있는 몇 가지 일반적인 접근 방식을 설명합니다.

개별적으로 테스트

ComposeTestRule을 사용하면 전체 애플리케이션이나 단일 화면, 작은 요소 등 컴포저블을 표시하는 활동을 시작할 수 있습니다. 또한 컴포저블이 올바르게 캡슐화되어 있고 독립적으로 작동하는지 확인하는 것도 좋은 방법입니다. 그러면 더 쉽고 집중적인 UI 테스트가 가능합니다.

그렇다고 오직 단위 UI 테스트만 만들어야 한다는 의미는 아닙니다. UI의 더 큰 부분을 범위로 지정하는 UI 테스트도 매우 중요합니다.

맞춤 시맨틱 속성

맞춤 시맨틱 속성을 만들어 테스트에 정보를 노출할 수 있습니다. 이렇게 하려면 새 SemanticsPropertyKey를 정의하고 SemanticsPropertyReceiver를 사용하여 사용 가능하도록 설정하세요.

// Creates a Semantics property of type boolean
val PickedDateKey = SemanticsPropertyKey<Long>("PickedDate")
var SemanticsPropertyReceiver.pickedDate by PickedDateKey

이제 semantics 수정자를 사용하여 이 속성을 사용할 수 있습니다.

val datePickerValue by remember { mutableStateOf(0L) }
MyCustomDatePicker(
    modifier = Modifier.semantics { pickedDate = datePickerValue }
)

테스트에서 SemanticsMatcher.expectValue를 사용하여 속성 값을 어설션할 수 있습니다.

composeTestRule
    .onNode(SemanticsMatcher.expectValue(PickedDateKey, 1445378400)) // 2015-10-21
    .assertExists()

디버깅

테스트에서 문제를 해결하는 주요 방법은 시맨틱 트리를 확인하는 것입니다. 테스트 중 언제든지 findRoot().printToLog()를 호출하여 트리를 출력할 수 있습니다. 이 함수는 다음과 같은 로그를 출력합니다.

Node #1 at (...)px
 |-Node #2 at (...)px
   OnClick = '...'
   MergeDescendants = 'true'
    |-Node #3 at (...)px
    | Text = 'Hi'
    |-Node #5 at (83.0, 86.0, 191.0, 135.0)px
      Text = 'There'

이러한 로그에는 버그를 추적하는 데 중요한 정보가 포함되어 있습니다.

Espresso와의 상호 운용성

하이브리드 앱에서는 Compose 컴포저블 내부의 뷰 계층 구조 및 뷰에서 (AndroidView 컴포저블을 통해) Compose 구성요소를 찾을 수 있습니다.

두 유형을 일치시키기 위해 특별한 단계가 필요하지는 않습니다. Espresso의 onView를 통해 뷰를 일치시키고 ComposeTestRule을 통해 Compose 요소를 일치시킵니다.

@Test
fun androidViewInteropTest() {
    // Check the initial state of a TextView that depends on a Compose state:
    Espresso.onView(withText("Hello Views")).check(matches(isDisplayed()))
    // Click on the Compose button that changes the state
    composeTestRule.onNodeWithText("Click here").performClick()
    // Check the new value
    Espresso.onView(withText("Hello Compose")).check(matches(isDisplayed()))
}

자세히 알아보기

자세한 내용은 Jetpack Compose 테스트 Codelab을 참고하세요.