최근 테스트 코드에 관련된 글을 읽으면서, Jetpack Compose 환경에서의 테스트가 아직 익숙하지 않음을 깨달았습니다. 이전 글에서 상태(State)와 상태 호이스팅(State Hoisting)을 다루면서 “호이스팅하면 테스트가 용이해진다”고 언급했지만, 실제로 얼마나 용이한지에 대해 깊이 고민해보지 못했습니다.
Jetpack Compose는 기존 Android View 시스템과는 테스트 방식이 다르므로, UI 테스트를 위해서는 Compose 전용 테스트 기법을 이해할 필요가 있습니다. 이 글에서는 상태가 호이스팅된 Compose UI를 어떻게 효과적으로 테스트할 것인지를 중심으로 살펴보겠습니다.
비교 항목 | 기존 View UI 테스트 (Espresso) | Jetpack Compose UI 테스트 |
---|---|---|
사용 방식 | onView(withId(R.id.button)) | onNodeWithText("확인") |
테스트 설정 | ActivityScenario 필요 | setContent {} 사용 가능 |
View 찾기 | ID 기반 | Semantics 기반 (텍스트, 태그 등) |
구현 복잡도 | XML 기반 UI 구조 필요 | 선언형 UI 방식 |
Jetpack Compose UI 테스트에서 자주 사용하는 개념과 메서드는 다음과 같습니다.
✅ ComposeTestRule: Compose UI를 제어하는 테스트 도구
✅ Recomposition & State 변경 감지: UI가 재구성되는지 확인
✅ UI 요소 찾기: onNodeWithText()
, onNodeWithTag()
등의 메서드 활용
✅ UI 이벤트 수행: performClick()
, performTextInput()
등의 메서드로 UI 조작
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++ }) {
Text("현재 카운트: $count")
}
}
@Composable
fun Counter(count: State<Int>, onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("현재 카운트: ${count.value}")
}
}
count
를 테스트 코드 측에서 마음대로 초기화해줄 수 있습니다.@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()
}
테스트에서 직접 상태를 제어하기 어려움
Counter 컴포저블이 내부적으로 상태를 관리하기 때문에, 테스트 코드에서 count
값을 임의로 변경하거나 초기값을 설정하기 힘듭니다.
비동기적 Recomposition 타이밍
버튼 클릭 직후 Compose가 UI를 재구성하는 타이밍과 테스트가 검증하는 시점이 어긋날 수 있습니다. 이를 피하기 위해서는 waitForIdle()
등을 사용해 UI 업데이트를 기다려야 합니다.
@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()
}
count
를 외부에서 관리하므로, 테스트 코드에서 count
를 원하는 값으로 설정할 수 있습니다.onIncrement
가 수행되면 count.value
만 변경하면 되고, Compose가 재구성을 알아서 해줍니다.ViewModel을 사용하면 실제 앱의 아키텍처와 유사한 구조에서 상태를 관리할 수 있고, 테스트 코드 역시 이를 검증하기 쉬워집니다.
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")
}
}
@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()
}
방식 | 장점 | 단점 |
---|---|---|
내부 상태(remember ) | 구현이 간단하고 코드가 짧음 | 외부에서 상태 제어가 어려워 테스트하기 어려움 |
상태 호이스팅(State Hoisting ) | UI와 상태를 분리해 외부에서 주입 가능 → 테스트 용이 | 호이스팅 범위를 잘못 설정하면 상위 컴포넌트가 복잡해질 수 있음 |
ViewModel(StateFlow /LiveData 등) | 중앙에서 상태 관리 → 실제 앱 구조와 유사, 테스트 안정성 높음 | 추가적인 ViewModel 코드 작성 필요 |
호이스팅 범위의 적절성
상태가 너무 상위로 올라가면 부모 컴포넌트가 불필요하게 비대해질 수 있습니다. 필요한 부분만 적절히 호이스팅하여 관리하는 것이 중요합니다.
테스트 성능 최적화
waitForIdle()
등으로 Recomposition 타이밍을 잘 제어합니다.waitForIdle()
: UI 업데이트 완료를 기다리는 함수로, 테스트의 안정성을 높여줍니다.)derivedStateOf
같은 최적화 기법으로 불필요한 Recomposition을 줄일 수 있습니다.derivedStateOf
: 상태 변경 시 필요한 계산만 수행하도록 캐싱하는 함수입니다.)초기 상태 설정
테스트 시 setContent {}
내부에서 ViewModel 또는 State의 초기값을 설정해, 원하는 시나리오를 재현하기 쉽도록 합니다.
상태 호이스팅은 Jetpack Compose 환경에서 UI 테스트를 쉽게 만들어줄 뿐 아니라, 코드 재사용성과 유지보수성까지 높여주는 강력한 기법입니다. 특히 ViewModel과 결합하면 실제 앱 구조와 동일한 형태로 테스트를 설계할 수 있어, UI 로직과 비즈니스 로직을 분리하고, 테스트 코드 역시 간결하고 직관적이게 작성할 수 있습니다.