본문 바로가기
안드로이드

Compose 1.11 업그레이드 후 테스트가 조용히 깨지는 이유

by 안드뽀개기 2026. 5. 14.
반응형

이 글이 맞는 프로젝트

Compose BOM 2026.04.01 이상으로 올리거나 올릴 예정인 프로젝트가 대상입니다. Compose 기반 UI 레이어가 있고, kotlinx-coroutines-test로 테스트를 작성하고 있다면 이 글에서 다루는 변경이 직접 영향을 줍니다. 최소 SDK는 특별히 요구되지 않지만, Kotlin 1.9 이상 환경을 전제합니다.

// build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.04.01"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")

문제 상황: 업그레이드했더니 멀쩡하던 테스트가 실패한다

BOM을 올리고 나서 이런 경험을 하는 경우가 많습니다. 로컬에서는 통과하던 Compose 관련 테스트가 CI에서 간헐적으로 실패하거나, 아예 특정 상태 변화가 반영되지 않아서 assertion이 통과되지 않는 것입니다.

이 문제의 원인은 Compose 1.11부터 테스트 디스패처의 기본값이 바뀐 것입니다. 기존 v1 API는 UnconfinedTestDispatcher를 사용해서 코루틴이 즉시 실행됐지만, v2 API는 StandardTestDispatcher를 기본으로 씁니다. StandardTestDispatcher에서는 코루틴이 즉시 실행되지 않고 큐에 쌓이다가 가상 클럭이 진행될 때 처리됩니다.


Before: 즉시 실행에 의존하던 테스트

// 기존 방식 (v1 API, 이제 deprecated)
@Test
fun `상품 목록 로딩 성공 시 상태가 업데이트된다`() = runComposeUiTest {
    var viewModel: ProductViewModel? = null

    setContent {
        viewModel = hiltViewModel()
        ProductScreen(viewModel = viewModel!!)
    }

    // UnconfinedTestDispatcher에서는 이 시점에 이미 코루틴이 완료된 상태
    onNodeWithText("상품 없음").assertDoesNotExist()
    onNodeWithText("아이폰").assertIsDisplayed()
}

UnconfinedTestDispatcher는 코루틴을 즉시 실행해줬기 때문에 setContent 이후에 바로 결과를 확인해도 문제가 없었습니다. 이 동작 방식이 오히려 레이스 컨디션을 숨기는 역할을 했고, 실제 기기에서는 재현되지만 테스트에서는 잡히지 않는 버그의 원인이 되기도 했습니다.


After: 가상 클럭을 명시적으로 제어하는 방식

// v2 API 방식
@Test
fun `상품 목록 로딩 성공 시 상태가 업데이트된다`() = runComposeUiTest {
    var viewModel: ProductViewModel? = null

    setContent {
        viewModel = hiltViewModel()
        ProductScreen(viewModel = viewModel!!)
    }

    // 코루틴 큐가 비워질 때까지 대기 (StandardTestDispatcher 기준)
    waitForIdle()

    onNodeWithText("로딩 중").assertDoesNotExist()
    onNodeWithText("아이폰").assertIsDisplayed()
}

waitForIdle()을 명시적으로 호출하면 Compose의 리컴포지션과 코루틴 처리가 모두 완료될 때까지 대기합니다. 이 방식이 실제 런타임 동작을 더 정확하게 시뮬레이션합니다.

비동기 작업이 여러 단계로 나뉘는 경우에는 advanceUntilIdle()을 함께 사용합니다.

@Test
fun `검색어 입력 후 디바운스 처리가 완료되면 결과가 표시된다`() = runComposeUiTest {
    setContent {
        SearchScreen()
    }

    onNodeWithTag("search_field").performTextInput("코틀린")

    // 디바운스 300ms를 가상 클럭으로 건너뜀
    mainClock.advanceTimeBy(400)
    waitForIdle()

    onNodeWithText("결과 없음").assertDoesNotExist()
    onAllNodesWithTag("search_result").assertCountEquals(5)
}

mainClock.advanceTimeBy()를 쓰면 실제로 300ms를 기다리지 않고도 디바운스 로직을 테스트할 수 있습니다. 이전에는 UnconfinedTestDispatcher가 타이머를 무시하고 즉시 실행해버려서 디바운스 자체가 테스트되지 않는 문제가 있었습니다.


공유 요소 전환 디버깅: 눈으로 확인하기

테스트 관련 변경 외에, 공유 요소(shared element) 전환이 이상하게 동작할 때 원인을 찾기 어려웠던 문제도 이번 릴리즈에서 개선됐습니다.

@Composable
fun ProductDetailTransition() {
    LookaheadAnimationVisualDebugging(
        overlayColor = Color(0x4AE91E63),
        isEnabled = BuildConfig.DEBUG, // 디버그 빌드에서만 활성화
        multipleMatchesColor = Color.Green,
        unmatchedElementColor = Color.Red,
        isShowKeylabelEnabled = false,
    ) {
        SharedTransitionLayout {
            CompositionLocalProvider(
                LocalSharedTransitionScope provides this,
            ) {
                NavHost(navController = rememberNavController(), startDestination = "list") {
                    composable("list") { ProductListScreen() }
                    composable("detail/{id}") { ProductDetailScreen() }
                }
            }
        }
    }
}

unmatchedElementColor = Color.Red로 설정하면 매칭이 안 된 요소가 빨간색으로 표시됩니다. 공유 요소 키가 잘못 설정됐거나 scope가 맞지 않을 때 즉시 확인할 수 있어서, 원인을 찾는 데 드는 시간이 크게 줄었습니다. 반드시 BuildConfig.DEBUG 조건을 달아서 릴리즈 빌드에는 포함되지 않도록 해야 합니다.


주의사항과 한계

v1 테스트 API가 deprecated 처리됐지만 당장 컴파일 오류가 나는 것은 아닙니다. 경고로만 표시되므로 느슨한 팀에서는 방치하기 쉽습니다. 반드시 -Werror나 린트 규칙을 통해 deprecated API 사용을 빌드 실패로 연결하는 것을 권장합니다.

StandardTestDispatcher로 전환하면 기존 테스트 일부가 실패할 수 있습니다. 이것은 테스트가 잘못된 게 아니라, 이전에 숨겨져 있던 레이스 컨디션이 드러나는 것입니다. 실패한 테스트를 억지로 통과시키려 하지 말고, 실제 비동기 흐름을 다시 검토하는 기회로 삼는 것이 좋습니다.

트랙패드 이벤트 개선은 PointerType.Mouse로 해석 방식이 바뀌면서 직접 터치 이벤트를 처리하는 커스텀 Modifier가 있다면 동작이 달라질 수 있습니다. 특히 PointerType.Touch를 명시적으로 체크하는 코드는 반드시 검토가 필요합니다.


지금 바로 시작하는 최소 단계

전체 테스트 스위트를 한꺼번에 바꾸려 하지 마세요. 가장 자주 깨지는 테스트 파일 하나를 골라서 waitForIdle() 호출을 추가해보는 것으로 시작하면 충분합니다. 그 파일에서 통과율이 안정되면 같은 방식으로 나머지 파일을 순차적으로 전환하면 됩니다.

※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형