ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flow의 UnitTest를 손쉽게 만드는 방법
    kotlin/Test 2025. 10. 28. 00:20

    OKR

    Objective

    Kotlin의 Flow/StateFlow를 app.cash.turbine으로 안정적으로 테스트하고, Android 프로젝트에서 재사용 가능한 테스트 패턴을 확립한다.

    Key Results

    • Turbine의 기본 개념과 장점을 이해한다.
    • Flow/StateFlow 테스트를 위한 핵심 API와 패턴을 습득한다.
    • runTest·가상 시간·advanceUntilIdle와 함께 Turbine을 올바르게 사용한다.
    • 실제 Android 코드(예: ViewModel 상태 스트림)를 검증하는 테스트 템플릿을 확보한다.

    1) Turbine 개요

    • Turbine은 Kotlin Flow를 테스트하기 위한 경량 라이브러리다.
    • test { ... } 블록에서 스트림을 수집하고, awaitItem() 같은 API로 방출 값을 순차적으로 검증한다.
    • 테스트가 끝나면 수집이 자동으로 취소되어 누수 없이 깔끔하다.

    2) 의존성 추가

    Gradle(Kotlin DSL) 예시:

    dependencies {
        testImplementation("app.cash.turbine:turbine:<latest-version>")
        testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<latest-version>")
        testImplementation("junit:junit:<latest-version>")
    }
    
    

    3) 최소 예시: Flow 테스트

    import app.cash.turbine.test
    import kotlin.test.Test
    import kotlin.test.assertEquals
    import kotlinx.coroutines.flow.flow
    import kotlinx.coroutines.test.runTest
    
    class FlowSampleTest {
        @Test
        fun emitsInOrder() = runTest {
            val target = flow {
                emit("A")
                emit("B")
                emit("C")
            }
    
            target.test {
                assertEquals("A", awaitItem())
                assertEquals("B", awaitItem())
                assertEquals("C", awaitItem())
                awaitComplete()
            }
        }
    }
    
    

    4) StateFlow 테스트 기본

    StateFlow는 구독 즉시 최신 값을 1번 방출한다. 따라서 테스트 시작 시 초기 상태를 먼저 검증하자.

    import app.cash.turbine.test
    import kotlin.test.Test
    import kotlin.test.assertEquals
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.test.runTest
    
    class StateFlowSampleTest {
        @Test
        fun stateFlowEmitsInitialThenUpdates() = runTest {
            val state = MutableStateFlow(0)
    
            state.test {
                assertEquals(0, awaitItem()) // 초기값
                state.value = 1
                assertEquals(1, awaitItem())
            }
        }
    }
    
    

    5) Android 실전 패턴: UI 상태 스트림 검증

    ViewModel 또는 매니저가 내보내는 StateFlow를 Turbine으로 검증한다.

    핵심은 “방출 순서”와 “여분 이벤트 없음”을 확인하는 것이다.

    검색 화면의 상태 스트림을 Turbine으로 검증하는 예제다.

    Idle → Loading → Success/Empty/Error 전이를 한 테스트에서 확인한다.

    // Production-like code (간단화를 위해 한 파일에 배치)
    
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.flow.*
    import kotlinx.coroutines.launch
    
    sealed interface SearchUiState {
        object Idle : SearchUiState
        object Loading : SearchUiState
        data class Success(val items: List<String>) : SearchUiState
        object Empty : SearchUiState
        data class Error(val message: String) : SearchUiState
    }
    
    interface SearchRepository {
        suspend fun search(query: String): List<String>
    }
    
    class SearchViewModel(
        private val repo: SearchRepository,
        private val scope: CoroutineScope
    ) {
        private val _state = MutableStateFlow<SearchUiState>(SearchUiState.Idle)
        val state: StateFlow<SearchUiState> = _state.asStateFlow()
    
        fun search(query: String) {
            scope.launch {
                _state.value = SearchUiState.Loading
                runCatching { repo.search(query) }
                    .onSuccess { list ->
                        _state.value = if (list.isEmpty()) SearchUiState.Empty
                        else SearchUiState.Success(list)
                    }
                    .onFailure { e ->
                        _state.value = SearchUiState.Error(e.message ?: "unknown error")
                    }
            }
        }
    }
    
    
    // Test code
    
    import app.cash.turbine.test
    import kotlin.test.Test
    import kotlin.test.assertEquals
    import kotlin.test.assertIs
    import kotlinx.coroutines.test.TestScope
    import kotlinx.coroutines.test.advanceUntilIdle
    import kotlinx.coroutines.test.runTest
    
    class SearchViewModelTest {
    
        private class FakeRepo : SearchRepository {
            override suspend fun search(query: String): List<String> = when (query) {
                "kotlin" -> listOf("kotlin-101", "kotlin-coroutines")
                "empty" -> emptyList()
                "boom" -> error("boom")
                else -> listOf(query)
            }
        }
    
        @Test
        fun search_emits_Idle_Loading_Success_then_Empty_and_Error() = runTest {
            val vm = SearchViewModel(repo = FakeRepo(), scope = TestScope(this).backgroundScope)
    
            vm.state.test {
                // 초기 상태
                assertEquals(SearchUiState.Idle, awaitItem())
    
                // 1) 성공 케이스
                vm.search("kotlin")
                assertEquals(SearchUiState.Loading, awaitItem())
                advanceUntilIdle()
                val success = awaitItem()
                assertIs<SearchUiState.Success>(success)
                assertEquals(listOf("kotlin-101", "kotlin-coroutines"), success.items)
    
                // 2) Empty 케이스
                vm.search("empty")
                assertEquals(SearchUiState.Loading, awaitItem())
                advanceUntilIdle()
                assertEquals(SearchUiState.Empty, awaitItem())
    
                // 3) Error 케이스
                vm.search("boom")
                assertEquals(SearchUiState.Loading, awaitItem())
                advanceUntilIdle()
                val error = awaitItem()
                assertIs<SearchUiState.Error>(error)
    
                cancelAndIgnoreRemainingEvents()
            }
        }
    }
    
    

    6) 자주 쓰는 Turbine API (요약)

    • test { ... } : Flow를 수집하며 테스트 블록 실행
    • awaitItem() : 다음 아이템이 방출될 때까지 대기해 값을 반환
    • awaitComplete() : 스트림 완료를 대기
    • expectNoEvents() : 더 이상의 이벤트가 없어야 함을 검증
    • cancelAndIgnoreRemainingEvents() : 남은 이벤트를 무시하고 종료
    • expectMostRecentItem() : StateFlow의 최신 값을 즉시 획득
    • awaitError() : 에러 방출을 대기하고 예외를 반환

    예시:

    flow.test {
        val first = awaitItem()
        expectNoEvents()
        cancelAndIgnoreRemainingEvents()
    }
    
    

    7) 코루틴 테스트와의 결합 포인트

    • runTest 환경에서는 가상 시간이 사용된다.
    • 비동기 작업(예: launch)이 완료되길 기다리려면 advanceUntilIdle()을 호출한다.
    • 지연·타이밍 의존 코드가 있다면 TestScope.currentTime·advanceTimeBy(...) 등을 활용하자.

    예시:

    @Test
    fun delayedEmission() = runTest {
        val target = someDelayedFlow() // 내부에서 delay 사용
    
        target.test {
            // 아직 방출 전
            expectNoEvents()
    
            // 지연을 가상 시간으로 스킵
            advanceUntilIdle()
    
            // 이제 값이 방출됨
            val v = awaitItem()
            cancelAndIgnoreRemainingEvents()
        }
    }
    

    8) 오류/취소 시나리오 검증

    에러를 방출하는 Flow는 awaitError()로 단정할 수 있다.

    import app.cash.turbine.test
    import kotlin.test.Test
    import kotlin.test.assertTrue
    import kotlinx.coroutines.flow.flow
    import kotlinx.coroutines.test.runTest
    
    class ErrorFlowTest {
        @Test
        fun emitsError() = runTest {
            val target = flow<Int> {
                throw IllegalStateException("boom")
            }
    
            target.test {
                val e = awaitError()
                assertTrue(e is IllegalStateException)
            }
        }
    }
    
    

    9) 상태 전이(Initial → Loading → Success/Empty/Error) 템플릿

    Android UI 상태를 다루는 전형적인 전이 시퀀스를 한 번에 검증한다.

    import app.cash.turbine.test
    import kotlin.test.Test
    import kotlin.test.assertEquals
    import kotlinx.coroutines.test.advanceUntilIdle
    import kotlinx.coroutines.test.runTest
    
    class UiStateMachineTest {
        @Test
        fun loadSequence() = runTest {
            val vm = /* ViewModel 생성 */
    
            vm.state.test {
                // Initial
                assertEquals(UiState.Initial, awaitItem())
    
                // Action
                vm.load()
    
                // Loading
                assertEquals(UiState.Loading, awaitItem())
    
                // 완료 후 최종 상태 (예: Success or Empty)
                advanceUntilIdle()
                val final = awaitItem()
                // when/then 분기별 검증
                // assertEquals(UiState.Success(data), final)
            }
        }
    }
    
    

    10) 실전 팁

    • 테스트가 길어지면 “Given/When/Then” 블록을 주석으로 구분해 가독성을 높인다.
    • StateFlow는 구독 즉시 1회 방출한다는 점을 항상 염두에 두고 첫 awaitItem()에서 초기 상태를 검증한다.
    • “이 시점에 더 이상 이벤트가 나오면 안 된다”는 것을 expectNoEvents()로 자주 확인한다.
    • 방출이 남아 있더라도 테스트를 종료하려면 cancelAndIgnoreRemainingEvents()로 깔끔히 마무리한다.
    • Mockito/fixture와 함께 사용할 때는, 방출 순서와 호출 순서를 명시적으로 분리해 테스트를 안정화한다.

    11) Turbine를 선택하는 이유

    • 직관적인 DSL로 방출 순서를 명확히 검증할 수 있다.
    • 수집/취소를 자동 관리하여 누수 위험이 낮다.
    • kotlinx-coroutines-test와 자연스럽게 결합되어 가상 시간 기반 테스트가 쉽다.
    • Android UI 상태 스트림(ViewModel의 StateFlow) 검증에 최적화되어 있다.

    'kotlin > Test' 카테고리의 다른 글

    Mockito를 활용한 모의 객체 설정: thenReturn vs doReturn  (0) 2024.12.02
Designed by Tistory.