Jetpack Compose 상태 호이스팅 이후, 어떻게 테스트할 것인가?

윤성현·2025년 2월 9일
1

글쓰기 챌린지

목록 보기
3/5
post-thumbnail

0. 서론

들어가면서

최근 테스트 코드에 관련된 글을 읽으면서, Jetpack Compose 환경에서의 테스트가 아직 익숙하지 않음을 깨달았습니다. 이전 글에서 상태(State)와 상태 호이스팅(State Hoisting)을 다루면서 “호이스팅하면 테스트가 용이해진다”고 언급했지만, 실제로 얼마나 용이한지에 대해 깊이 고민해보지 못했습니다.

Jetpack Compose는 기존 Android View 시스템과는 테스트 방식이 다르므로, UI 테스트를 위해서는 Compose 전용 테스트 기법을 이해할 필요가 있습니다. 이 글에서는 상태가 호이스팅된 Compose UI를 어떻게 효과적으로 테스트할 것인지를 중심으로 살펴보겠습니다.

1. Jetpack Compose UI 테스트의 기본 개념

1.1 기존 Android View 시스템의 UI 테스트와의 차이점

  • 기존 View 시스템에서는 Espresso를 활용한 UI 테스트가 UI 트리 탐색 방식으로 인해 복잡했습니다.
  • Jetpack Compose에서는 ComposeTestRule을 활용하여 더 직관적으로 UI 테스트를 작성할 수 있습니다.
    비교 항목기존 View UI 테스트 (Espresso)Jetpack Compose UI 테스트
    사용 방식onView(withId(R.id.button))onNodeWithText("확인")
    테스트 설정ActivityScenario 필요setContent {} 사용 가능
    View 찾기ID 기반Semantics 기반 (텍스트, 태그 등)
    구현 복잡도XML 기반 UI 구조 필요선언형 UI 방식

1.2 Jetpack Compose UI 테스트 주요 개념

Jetpack Compose UI 테스트에서 자주 사용하는 개념과 메서드는 다음과 같습니다.

ComposeTestRule: Compose UI를 제어하는 테스트 도구

Recomposition & State 변경 감지: UI가 재구성되는지 확인

UI 요소 찾기: onNodeWithText(), onNodeWithTag() 등의 메서드 활용

UI 이벤트 수행: performClick(), performTextInput() 등의 메서드로 UI 조작


2. 상태 호이스팅이 UI 테스트를 더 쉽게 만드는 이유

2.1 상태가 내부에 있을 때의 문제점

@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Button(onClick = { count++ }) {
        Text("현재 카운트: $count")
    }
}
  • 컴포넌트 내부에서 상태를 직접 관리하면 외부에서 상태를 주입하거나 변경하기 어렵습니다.
  • UI와 비즈니스 로직이 강하게 결합되어 테스트하기 복잡해집니다.

2.2 상태를 호이스팅하면 달라지는 점

@Composable
fun Counter(count: State<Int>, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) {
        Text("현재 카운트: ${count.value}")
    }
}
  • 테스트할 때 count테스트 코드 측에서 마음대로 초기화해줄 수 있습니다.
  • “현재 카운트가 10인 상태에서 시작”과 같은 특정 상황을 아주 쉽게 재현할 수 있습니다.
  • UI 로직(화면을 어떻게 그릴지)과 상태 관리 로직(값을 올리거나 초기화하는 등)이 명확히 분리되어, 단위 테스트와 UI 테스트가 각각 독립적으로 움직일 수 있습니다.

3. 테스트 코드와 함께 알아보기

3.1 내부 상태 관리 버전 (테스트가 어려운 예시)

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun test_counter_internal_state() {
    composeTestRule.setContent {
        Counter() // 내부 상태를 직접 remember { mutableIntStateOf(0) }로 관리
    }

    // 초기 상태 확인
    composeTestRule.onNodeWithText("현재 카운트: 0").assertExists()

    // 버튼 클릭
    composeTestRule.onNodeWithText("현재 카운트: 0").performClick()

    // 상태 변화 검증
    composeTestRule.onNodeWithText("현재 카운트: 1").assertExists()
}

3.1.1 이 테스트가 어려운 이유

  1. 테스트에서 직접 상태를 제어하기 어려움

    Counter 컴포저블이 내부적으로 상태를 관리하기 때문에, 테스트 코드에서 count 값을 임의로 변경하거나 초기값을 설정하기 힘듭니다.

  2. 비동기적 Recomposition 타이밍

    버튼 클릭 직후 Compose가 UI를 재구성하는 타이밍과 테스트가 검증하는 시점이 어긋날 수 있습니다. 이를 피하기 위해서는 waitForIdle() 등을 사용해 UI 업데이트를 기다려야 합니다.

3.2 상태 호이스팅 버전 (테스트가 쉬운 예시)

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun test_counter_hoisted_state() {
    val count = mutableIntStateOf(0)

    composeTestRule.setContent {
        Counter(
            count = count,
            onIncrement = { count.value++ }
        )
    }

    // 초기 상태가 0인지 확인
    composeTestRule.onNodeWithText("현재 카운트: 0").assertExists()

    // 버튼 클릭 (1로 증가)
    composeTestRule.onNodeWithText("현재 카운트: 0").performClick()
    composeTestRule.onNodeWithText("현재 카운트: 1").assertExists()

    // 테스트 중간에 임의로 count를 5로 바꾸고 싶다면?
    count.value = 5
    // UI가 "현재 카운트: 5"로 재구성되었는지 확인
    composeTestRule.onNodeWithText("현재 카운트: 5").assertExists()
}

3.2.1 왜 이 구조는 쉽게 테스트할 수 있을까?

  • count외부에서 관리하므로, 테스트 코드에서 count를 원하는 값으로 설정할 수 있습니다.
  • onIncrement가 수행되면 count.value만 변경하면 되고, Compose가 재구성을 알아서 해줍니다.
  • 테스트 시나리오 작성이 단순해지고, 동작이 안정적으로 검증됩니다.

4. ViewModel과 함께 상태 호이스팅하기

ViewModel을 사용하면 실제 앱의 아키텍처와 유사한 구조에서 상태를 관리할 수 있고, 테스트 코드 역시 이를 검증하기 쉬워집니다.

4.1 ViewModel 및 변경된 Counter 코드

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() {
        _count.value++
    }
}

@Composable
fun Counter(viewModel: CounterViewModel) {
    val count by viewModel.count.collectAsState()

    Button(onClick = { viewModel.increment() }) {
        Text("현재 카운트: $count")
    }
}

4.2 테스트 코드

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

@Test
fun test_counter_with_viewmodel() {
    val viewModel = CounterViewModel()

    composeTestRule.setContent {
        Counter(viewModel = viewModel)
    }

    // 초기 상태 확인
    composeTestRule.onNodeWithText("현재 카운트: 0").assertExists()

    // ViewModel 상태 변경
    viewModel.increment()

    // Compose가 ViewModel 상태 변화를 반영하는지 검증
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText("현재 카운트: 1").assertExists()
}

5. 최종 정리 및 추가 고려 사항

5.1 방식별 장단점 비교

방식장점단점
내부 상태(remember)구현이 간단하고 코드가 짧음외부에서 상태 제어가 어려워 테스트하기 어려움
상태 호이스팅(State Hoisting)UI와 상태를 분리해 외부에서 주입 가능 → 테스트 용이호이스팅 범위를 잘못 설정하면 상위 컴포넌트가 복잡해질 수 있음
ViewModel(StateFlow/LiveData 등)중앙에서 상태 관리 → 실제 앱 구조와 유사, 테스트 안정성 높음추가적인 ViewModel 코드 작성 필요

5.2 요약

  • 상태를 컴포넌트 내부에서만 관리하면 테스트가 복잡해질 수 있습니다.
  • 상태 호이스팅을 통해 UI와 비즈니스 로직을 분리하면, 테스트 코드 작성이 훨씬 쉬워집니다.
  • ViewModel을 병행 사용하면 실제 앱과 유사한 구조에서 테스트할 수 있고, 유지보수 측면에서도 유리합니다.

5.3 상태 호이스팅과 테스트 적용 시 고려할 점

  1. 호이스팅 범위의 적절성

    상태가 너무 상위로 올라가면 부모 컴포넌트가 불필요하게 비대해질 수 있습니다. 필요한 부분만 적절히 호이스팅하여 관리하는 것이 중요합니다.

  2. 테스트 성능 최적화

    • waitForIdle() 등으로 Recomposition 타이밍을 잘 제어합니다.
      (waitForIdle() : UI 업데이트 완료를 기다리는 함수로, 테스트의 안정성을 높여줍니다.)
    • derivedStateOf 같은 최적화 기법으로 불필요한 Recomposition을 줄일 수 있습니다.
      (derivedStateOf : 상태 변경 시 필요한 계산만 수행하도록 캐싱하는 함수입니다.)
  3. 초기 상태 설정

    테스트 시 setContent {} 내부에서 ViewModel 또는 State의 초기값을 설정해, 원하는 시나리오를 재현하기 쉽도록 합니다.


6. 결론

상태 호이스팅은 Jetpack Compose 환경에서 UI 테스트를 쉽게 만들어줄 뿐 아니라, 코드 재사용성과 유지보수성까지 높여주는 강력한 기법입니다. 특히 ViewModel과 결합하면 실제 앱 구조와 동일한 형태로 테스트를 설계할 수 있어, UI 로직과 비즈니스 로직을 분리하고, 테스트 코드 역시 간결하고 직관적이게 작성할 수 있습니다.

0개의 댓글