
컴포즈 기반으로 종단 간(End-to-end, E2E) 테스트를 작성할 때, 테스트를 잘못 구성하면 간헐적으로 테스트가 실패하는 문제가 발생할 수 있습니다. 영어로는 이러한 테스트를 ‘Flaky Test’라고 부르는데, 말 그대로 상황에 따라 성공할 수도, 실패할 수도 있는 변덕스러운 테스트를 뜻합니다. 이 글에서는 테스트 불안정성이 발생할 수 있는 원인, 해결 방법, 그리고 관련된 동작 원리를 정리해보고자 합니다.
실제 경험하였던 사례를 간소화하여 재구성하며 관련 내용을 살펴보도록 하겠습니다. 식상한 예시지만, 해야 일의 목록을 확인하고 추가하는 기능을 구현해 보겠습니다.
ViewModel부터 간단히 구현해 보면, 평소 정말 많이 사용하는 비동기 처리 방식대로 코드를 작성해볼 수 있습니다.
class TodoViewModel : ViewModel() {
private val _uiState = MutableStateFlow(TodoState())
val uiState: StateFlow<TodoState> = _uiState
fun addTodo(text: String) {
viewModelScope.launch {
// 비동기 작업(생략)
// 상태 업데이트
_uiState.update { currentState ->
val newItems = currentState.items + text
currentState.copy(items = newItems)
}
}
}
}
data class TodoState(val items: List<String> = emptyList())
다음으로 사용자가 화면 상의 버튼을 클릭하면 뷰모델의 함수를 트리거하고, 그에 따라 화면에 보여야 하는 것을 변경할 수 있도록 구현합니다.
@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var text by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
// 텍스트 필드 + 추가 버튼
Row {
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f)
)
Button(onClick = {
viewModel.addTodo(text)
text = "" // 입력 필드 초기화
}) {
Text("추가")
}
}
// 목록 보여주기
LazyColumn {
items(uiState.items) { item ->
Text(item, modifier = Modifier.padding(8.dp))
}
}
}
}
테스트 클래스에서는 버튼 클릭 후 UI 상태가 올바르게 변경되었는지 검증할 수 있습니다. Compose 측에서 제공해주는 test rule을 활용하여, 손쉽게 테스트를 작성한 모습입니다.
class TodoScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun addTodo_item_appears_in_list_FLAKY() {
// given
composeTestRule.setContent { TodoScreen() }
// when
composeTestRule.onNode(hasSetTextAction()).performTextInput("테스트 학습")
composeTestRule.onNodeWithText("추가").performClick()
// then
composeTestRule.onNodeWithText("테스트 학습").assertIsDisplayed() // ?
}
}
이 테스트 케이스는 겉보기에 아무런 문제가 없어 보입니다. 하지만 이 테스트가 ‘항상’ 성공’만’ 할 것인가를 보장할 수 있을까 고민해 볼 필요가 있습니다. 만약 현재 코드를 그대로 사용하면, 불안정한 테스트로 그치게 될 것입니다. 그 이유를 이어질 내용에서 살펴보도록 하겠습니다.
초기에는 로컬에서뿐만 아니라 CI 환경에서도 해당 테스트 케이스가 별다른 문제 없이 통과함을 확인하였습니다. 그러나 이후 다른 CI 파이프라인이 동작하는 과정에서, 아래와 같이 Firebase Test Lab으로부터 일부 테스트 케이스가 불안정하다는 경고를 접하게 되었습니다. 로컬 환경에서는 항상 성공하던 테스트에서 이러한 경고가 나타나면서, 테스트 안정성에 대한 의문이 들었습니다.
당시 스택 트레이스에서는, 아래와 같은 예외가 발생하고 있었습니다.
java.lang.IllegalArgumentException: performMeasureAndLayout called during measure layout
이 예외가 의미하는 바는, Compose UI가 레이아웃을 측정하고 배치하는 과정 중에 강제로 다시 측정과 배치를 수행하려고 시도했다는 것입니다. 별 문제 없어 보이는 테스트 코드에 이러한 일이 발생한 이유는 recomposition 원리와 관련이 있습니다. 컴포즈는 상태가 변경되면 자동으로 recomposition을 트리거하여 UI를 다시 그리는데, 이는 다음 단계에 따라 진행됩니다.
Compose UI 테스트에서 assertIsDisplayed()와 같은 검증을 실행할 때, 컴포즈의 Test Rule은 검증을 수행하기 직전에 UI 트리의 상태가 최신인지 확인해야 합니다. 이때, 필요한 경우 내부적으로 performMeasureAndLayout()을 호출하여 Layout 단계를 트리거할 수 있습니다.
만약 UI를 띄우는 과정에서 정상적인 Layout 단계가 진행 중인 상황에서 테스트 검증부가 실행되면, Layout 작업 도중에 또 다른 Layout 작업을 시작하려는 상황이 발생할 수 있음을 짐작해볼 수 있습니다. 하지만 Compose는 이를 허용하지 않기 때문에 예외가 발생하게 되는 것입니다.
다시 말해, UI가 아직 완전히 구성되기 전에 테스트 검증부가 실행되어 발생한 동시성 문제로 볼 수 있습니다. performClick() 이후 Compose UI의 업데이트와 ViewModel의 비동기 처리가 모두 완료되기 전에, 테스트가 다음 줄의 검증부로 넘어간 경우가 어쩌다 한 번씩은 존재했기 때문에 이 문제가 발생했음을 알 수 있습니다.
Compose 테스트의 performClick()은 UI 상호 작용 자체는 동기화하지만, 그로 인해 백그라운드에서 트리거된 코루틴 작업까지 완료되기를 기다려주지는 않습니다. 따라서 아래 시나리오에 따라 테스트가 성공할 수도, 실패할 수도 있는 것입니다.
ViewModel의 비동기 작업이 매우 빠르게 완료 시 - 테스트 성공 (assertion이 실행될 때 UI 업데이트가 이미 끝난 상태)ViewModel의 비동기 작업이 느리게 완료 시 - 테스트 실패 (assertion이 실행될 때 UI 업데이트가 진행 중인 상태)즉, 테스트 환경의 부하, CPU 스케줄링 등에 따라 비동기 작업의 완료 시점이 달라지는 현상으로 인하여 테스트가 불안정할 수 있는 것입니다.
공식 문서에 따르면, 본래 컴포즈 Test Rule의 performClick()이나 assertIsDisplayed() 같은 메서드를 호출하면, 테스트는 Compose UI 트리 내부가 Idle 상태가 될 때까지 자동으로 기다립니다. 즉, 일반적으로는 Composition, Layout, Drawing과 같은 Compose Runtime 자체의 작업이 완료되는 것을 보장합니다.
하지만 이러한 자동 동기화에도 한계가 존재합니다. 앞서 살펴본 뷰모델의 함수와 같이, Compose 측에서는 외부에 존재하는 비동기 코루틴 작업이 완료되는 것을 보장하지 않습니다. 따라서 비동기 로직이 포함된 흐름을 안정적으로 검증하려면, Compose의 자동 동기화 범위를 넘어선 영역을 명시적으로 제어해야 합니다.
문제의 근본 원인인 코루틴 비동기 작업 자체를 테스트 환경에서 제어하는 것입니다.
Kotlin Coroutines의 runTest와 TestDispatcher를 사용하여 ViewModel 내의 비동기 작업 완료 시점을 명확하게 통제할 수 있습니다.
class MainDispatcherRule(
val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
// 테스트 시작 시 Main Dispatcher를 테스트 디스패처로 설정합니다.
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
// 테스트 종료 시 Main Dispatcher를 원래대로 복원합니다.
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
class TodoScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun addTodo_item_appears_in_list_STABLE() = runTest {
// given
composeTestRule.setContent { TodoScreen() }
// when
composeTestRule.onNode(hasSetTextAction()).performTextInput("테스트 학습")
composeTestRule.onNodeWithText("추가").performClick()
// then
// UI가 비동기 작업 결과로 업데이트되기를 기다린 후 검증
composeTestRule.onNodeWithText("테스트 학습").assertIsDisplayed()
}
}
runTest 환경에서는 비동기 작업의 가상 시간을 빠르게 진행시켜, 실제 시간을 기다리지 않고 작업을 즉시 완료시킵니다. 이를 위해 ViewModel의 Dispatcher를 교체하는 TestRule을 적용하고 테스트 함수를 runTest 형태로 작성합니다. 이렇게 하면 performClick()이 트리거한 코루틴이 즉시 완료되고, UI 업데이트가 동기적으로 처리되어 검증부를 실행할 때는 안정적으로 최종 상태를 검증할 수 있습니다.
ComposeTestRule이 제공하는 waitForIdle()을 수동으로 사용하면서 더욱 견고한 테스트를 작성할 수 있습니다.
waitForIdle()의 동작 방식
waitForIdle() 함수는 Compose가 idle 상태가 될 때까지 기다리지만, autoAdvance 속성에 따라 동작이 다릅니다:
autoAdvance = true (기본값): 시계를 Compose가 idle 상태가 될 때까지 진행시킵니다autoAdvance = false: idling resources만 idle 상태가 될 때까지 기다립니다두 경우 모두 waitForIdle()은 보류 중인 draw 및 layout pass를 기다립니다 (출처: Android Developers - Synchronize your tests).
수행하는 핵심 작업
autoAdvance가 true일 때, 모든 보류 중인 코루틴 작업이 완료될 때까지 가상 시간을 진행시킵니다.composeTestRule.waitForIdle()을 호출하면, Compose는 UI 트리가 Idle 상태가 될 때까지 기다립니다. 기본 설정(autoAdvance = true)에서 이 함수는 Compose의 가상 시간을 진행시켜 모든 코루틴 작업이 완료되도록 만듭니다. 이후 발생하는 모든 Recomposition, Layout, Draw 단계를 기다려 안정적으로 검증부를 수행할 수 있도록 합니다.
class TodoScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun addTodo_item_appears_in_list_STABLE_with_waitForIdle() {
// given
composeTestRule.setContent { TodoScreen() }
// when
composeTestRule.onNode(hasSetTextAction()).performTextInput("테스트 학습")
composeTestRule.onNodeWithText("추가").performClick()
// 비동기 작업 + 그에 따른 UI 업데이트 완료 대기
composeTestRule.waitForIdle()
// then
composeTestRule.onNodeWithText("테스트 학습").assertIsDisplayed()
}
}
두 방식에 관하여 요약하면 다음과 같습니다.
| 구분 | 테스트 전용 코루틴 (runTest / TestDispatcher) | waitForIdle() (Test Rule 활용) |
|---|---|---|
| 주요 접근 방식 | 코루틴 제어 (시간 조작) | UI 상태 대기 (Idle 상태 보장) |
| 작동 원리 | ViewModel 내의 비동기 작업의 가상 시간을 빠르게 진행시켜, 비동기 작업을 즉시 완료시킵니다. | Compose Runtime에 "UI가 완전히 안정화될 때까지 기다리라"고 명령하여, UI 스레드와 코루틴 작업을 포함한 모든 대기 중인 작업을 완료시킵니다. |
| 장점 | 가장 정확하고 권장됨. 비동기 작업의 완료 시점을 명확히 예측 가능하게 만들어 테스트의 안정성을 최고로 높입니다. | 구현이 비교적 단순하며, UI 컴포넌트의 Layout 안정화를 보장합니다. |
| 적합한 상황 | ViewModel 내 코루틴 로직이 포함된 E2E 테스트. | 순수 Compose UI의 안정화 대기, 또는 runTest 설정이 어려운 환경. |
안정적인 종단 간 테스트를 위해서는 테스트의 특성에 따라 적절한 동기화 방법을 적용하여 비동기 작업 완료 시점을 명확히 통제하는 것이 중요합니다.