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 for createAndroidComposeRule<YourActivity>():
debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

이 모듈에는 ComposeTestRuleAndroidComposeTestRule라는 Android용 구현이 포함되어 있습니다. 이 규칙을 통해 Compose 콘텐츠를 설정하거나 활동에 액세스할 수 있습니다. 규칙은 팩토리 함수 createComposeRule 또는 createAndroidComposeRule(활동에 액세스해야 하는 경우)를 사용하여 구성됩니다. 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 }

waitUntil 도우미를 사용할 수도 있습니다.

composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMs)

composeTestRule.waitUntilDoesNotExist(matcher, timeoutMs)

composeTestRule.waitUntilExactlyOneExists(matcher, timeoutMs)

composeTestRule.waitUntilNodeCount(matcher, count, timeoutMs)

일반적인 패턴

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

개별적으로 테스트

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

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

자체 콘텐츠를 설정한 후 활동 및 리소스에 액세스

예를 들어 표시된 텍스트가 문자열 리소스와 일치하는지 어설션하기 위해 composeTestRule.setContent를 사용하여 테스트 중인 콘텐츠를 설정해야 하고 활동 리소스에도 액세스해야 하는 경우가 종종 있습니다. 그러나 활동에서 이미 호출한 경우 createAndroidComposeRule()로 만든 규칙에서 setContent를 호출할 수 없습니다.

이를 위한 일반적인 패턴은 빈 활동(예: ComponentActivity)을 사용하여 AndroidComposeTestRule을 만드는 것입니다.

class MyComposeTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()

    @Test
    fun myTest() {
        // Start the app
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = exampleUiState, /*...*/)
            }
        }
        val continueLabel = composeTestRule.activity.getString(R.string.next)
        composeTestRule.onNodeWithText(continueLabel).performClick()
    }
}

ComponentActivity를 앱의 AndroidManifest.xml 파일에 추가해야 합니다. 이 종속 항목을 모듈에 추가하면 됩니다.

debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version")

맞춤 시맨틱 속성

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

// Creates a Semantics property of type Long
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()

상태 복원 확인

활동이나 프로세스가 다시 만들어질 때 Compose 요소의 상태가 올바르게 복원되는지 확인해야 합니다. StateRestorationTester 클래스를 사용하여 활동 재생성에 의존하지 않고 이러한 검사를 실행할 수 있습니다.

이 클래스를 사용하면 컴포저블의 재생성을 시뮬레이션할 수 있습니다. rememberSaveable의 구현을 확인하는 것이 특히 유용합니다.


class MyStateRestorationTests {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun onRecreation_stateIsRestored() {
        val restorationTester = StateRestorationTester(composeTestRule)

        restorationTester.setContent { MainScreen() }

        // TODO: Run actions that modify the state

        // Trigger a recreation
        restorationTester.emulateSavedInstanceStateRestore()

        // TODO: Verify that state has been correctly restored.
    }
}

다양한 기기 설정 테스트

Android 앱은 창 크기, 언어, 글꼴 크기, 어두운 테마와 밝은 테마 등 다양한 변화하는 조건에 맞게 조정해야 합니다. 이러한 조건의 대부분은 사용자가 제어하고 현재 Configuration 인스턴스와 함께 노출되는 기기 수준 값에서 파생됩니다. 테스트에서 기기 수준 속성을 구성해야 하므로 테스트에서 여러 구성을 직접 테스트하기란 어렵습니다.

DeviceConfigurationOverride은 테스트 전용 API로, 테스트 중인 @Composable 콘텐츠에 관해 현지화된 방식으로 다양한 기기 구성을 시뮬레이션할 수 있습니다.

DeviceConfigurationOverride의 컴패니언 객체에는 기기 수준 구성 속성을 재정의하는 다음 확장 함수가 있습니다.

특정 재정의를 적용하려면 DeviceConfigurationOverride() 최상위 함수 호출에서 테스트 중인 콘텐츠를 래핑하고 매개변수로 적용할 재정의를 전달합니다.

예를 들어 다음 코드는 DeviceConfigurationOverride.ForcedSize() 재정의를 적용하여 밀도를 로컬에서 변경하므로 테스트가 실행되는 기기에서 창 크기를 직접 지원하지 않더라도 MyScreen 컴포저블을 큰 가로 모드 창에서 강제로 렌더링합니다.

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.ForcedSize(DpSize(1280.dp, 800.dp))
    ) {
        MyScreen() // will be rendered in the space for 1280dp by 800dp without clipping
    }
}

여러 재정의를 함께 적용하려면 DeviceConfigurationOverride.then()을 사용합니다.

composeTestRule.setContent {
    DeviceConfigurationOverride(
        DeviceConfigurationOverride.FontScale(1.5f) then
            DeviceConfigurationOverride.FontWeightAdjustment(200)
    ) {
        Text(text = "text with increased scale and weight")
    }
}

디버깅

테스트에서는 주로 시맨틱 트리를 확인하여 문제를 해결할 수 있습니다. 테스트 중 언제든지 composeTestRule.onRoot().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()))
}

UiAutomator와의 상호 운용성

기본적으로 컴포저블은 편리한 설명자(표시된 텍스트, 콘텐츠 설명 등)를 통해서만 UiAutomator에 액세스할 수 있습니다. Modifier.testTag를 사용하는 모든 컴포저블에 액세스하려면 특정 컴포저블 하위 트리에 testTagsAsResourceId 시맨틱 속성을 사용 설정해야 합니다. 이 동작을 사용 설정하면 스크롤 가능한 컴포저블과 같이 다른 고유 핸들이 없는 컴포저블(예: LazyColumn)에 유용합니다.

UiAutomator에서 Modifier.testTag와 함께 중첩된 모든 컴포저블에 액세스할 수 있도록 컴포저블 계층 구조에서 한 번만 사용 설정할 수 있습니다.

Scaffold(
    // Enables for all composables in the hierarchy.
    modifier = Modifier.semantics {
        testTagsAsResourceId = true
    }
){
    // Modifier.testTag is accessible from UiAutomator for composables nested here.
    LazyColumn(
        modifier = Modifier.testTag("myLazyColumn")
    ){
        // content
    }
}

Modifier.testTag(tag)를 포함하는 모든 컴포저블은 resourceName과 동일한 tagBy.res(resourceName)을 사용하여 액세스할 수 있습니다.

val device = UiDevice.getInstance(getInstrumentation())

val lazyColumn: UiObject2 = device.findObject(By.res("myLazyColumn"))
// some interaction with the lazyColumn

자세히 알아보기

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

샘플