ABOUT ME

-

  • Android) Kotlin Coroutines 1.6에서 도입된 테스트 API 사용해보기
    Android 2022. 8. 21. 20:42


    Kotlin Coroutines 1.6에서 새로 도입된 테스트 API로 기존의 테스트 코드 migration을 진행해보려고 합니다.

     

    Build.gradle

    dependencies {
        testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" // 1.6
    }

     

    runTest

    테스트 코드에서 suspend 함수를 호출하기 위해서는 코루틴 블록 내에서 호출해야 합니다.

     

    • Coroutines 1.6 이전 버전에서는 runBlockingTest 테스트용 코루틴 빌더를 통해 코루틴이 포함된 테스트를 래핑 하여 사용할 수 있습니다.
    • Coroutines 1.6 버전에서 deprecated 되었고 2022년 내에 완전히 제거될 예정이라고 합니다.
    • Coroutines 1.6 버전부터는 runTest 테스트용 코루틴 빌더를 통해 테스트를 할 수 있습니다.

     

    suspend fun fetchData() : String {
        delay(1000L)
        return "Hello world"
    }
    
    // Since 1.6.0, ERROR in 1.7.0 and removed as experimental in 1.8.0
    /*@Test
    fun dataShouldBeHelloWordl() = runBlockingTest {
        val data = fetchData()
        assertEquals("Hello world", data)
    }*/
    
    @Test
    fun dataShouldBeHelloWordl() = runTest {
        val data = fetchData()
        assertEquals("Hello world", data)
    }

     

    TestDispatchers

    Android UI Thread는 유닛 테스트에 적합하지 않기 때문에, TestDispatcher로 바꿔치기 해서 사용해야 합니다.

    TestDispatcher에는 StandardTestDispatcher, UnconfinedTestDispatcher 두 가지가 있습니다.

     

    StandartTestDispatcher 

    • 새 코루틴을 시작하면 코루틴이 기본 스케줄러의 대기열에 추가되어 테스트 스레드를 사용할 수 있을 때마다 실행됩니다.
    • 자체적으로 작업을 실행하지 않고 항상 스케줄러에게 작업을 전달.
    • runTest는 기본적으로 StandartTestDispatcher를 사용.
    • 실행 순서에 대해 완전한 제어 가능하지만, 코루틴이 자동으로 실행되지 않습니다.
    • 코루틴의 실행 순서나 디테일한 제어가 필요한 테스트에 적합.

     

    @Test
    fun standardTest() = runTest {
        val userRepo = UserRepository()
    
        launch { userRepo.register("Alice") }
        launch { userRepo.register("Bob") }
    
        assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers())
    }

    아래의 테스트 코드를 실행해보면 테스트가 실패했다는 결과가 나옵니다.

     

    테스트 스레드가 최상위 테스트 코루틴을 실행하는 동안 생성되지 않으면, 모든 새 코루틴은 테스트 코루틴이 완료된 후에만 실행되기 때문입니다. (runTest가 반환하기 전)

     

     

    대기열에 추가된 코루틴이 실행되도록 테스트 코루틴을 생성하는 방법도 있습니다. 

     

    • advanceUntilIdle : 코루틴이 모두 실행되도록 대기열에 남은 항목이 없을 때까지 스케줄러에서 다른 코루틴을 모두 실행.
    • advanceTimeBy : 주어진 시간을 진행하고 해당 지점 전에 실행되도록 예약된 코루틴을 실행. 
    • runCurrent : 예약된 코루틴을 실행. 아직 디스패치 않은 작업을 수행.

     

    @Test
    fun standardTest() = runTest {
        val userRepo = UserRepository()
    
        launch { userRepo.register("Alice") }
        launch { userRepo.register("Bob") }
        advanceUntilIdle()
        
        assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers())
    }

     

    UnconfinedTestDispatcher 

     

    • 새 코루틴이 UnconfinedTestDispatcher에서 시작되면 현재 스레드에서 빠르게 시작됩니다.
    • 즉 코루틴 빌더가 반환될 때까지 기다리지 않고 즉시 실행됩니다.
    • 새 코루틴이 실행되도록 테스트 스레드를 수동으로 생성하지 않아도 되기 때문에, 새 코루틴을 빠르게 실행하며 코루틴을 사용한 간단한 테스트에 적합.
    • 그래서 advanceUntilIdle, runCurrent 같은 함수를 호출할 필요가 없습니다.
    • 실행 순서에 대한 완전한 제어가 불가능하지만, 코루틴이 자동으로 실행됩니다.

     

    @Test
    fun standardTest() = runTest(UnconfinedTestDispatcher()) {
        val userRepo = UserRepository()
    
        launch { userRepo.register("Alice") }
        launch { userRepo.register("Bob") }
    
        assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers())
    }

     

    • 새 코루틴을 빠르게 시작하지만, 완료될 때까지 빠르게 실행하는 것은 아닙니다. 새 코루틴이 정지되면 다른 코루틴이 실행을 다시 시작하게 됩니다.
    • 아래 테스트 코드에서 새 코루틴은 Alice를 등록하지만, delay가 호출되면 정지됩니다. Bob이 아직 등록되지 않았기 때문에 결과는 실패로 나옵니다.

     

    @Test
    fun standardTest() = runTest(UnconfinedTestDispatcher()) {
        val userRepo = UserRepository()
    
        launch {
            userRepo.register("Alice")
            delay(10L)
            userRepo.register("Bob")
        }
    
        assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers())
    }

     

    Handle the Main Thread

     

    유닛 테스트에서는 Android UI 스레드를 래핑하는 Main 디스패처를 사용할 수 없습니다. 유닛 테스트는 로컬 JVM에서 실행되기 때문에 테스트 중인 코드가 기본 스레드를 참조하면 예외가 발생합니다.

     

    그래서 Main 디스패처에서 의존하는 viewModelScope 같은 경우, TestDispatcher로 교체해서 아래의 코드와 같이 테스트 전반에 사용하는 Rule 클래스를 생성하여 테스트를 진행해왔습니다.

     

    Main 디스패처TestDispatcher로 교체되었다면 새로 만들어진 TestDispatcher는 자동으로 Main 디스패처의 스케줄러를 사용합니다. 

     

    class MainCoroutineRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
        TestWatcher() {
        override fun starting(description: Description?) {
            super.starting(description)
            Dispatchers.setMain(testDispatcher)
        }
    
        override fun finished(description: Description?) {
            super.finished(description)
            Dispatchers.resetMain()
            testDispatcher.cleanupTestCoroutines()
        }
    }

     

    • runBlockingTest가 테스트 내에서 생성된 새로운 코루틴을 즉시 시작하는 동안 runTest는 기본적으로 테스트 코루틴에 대해 StandardTestDispatcher를 사용하기 때문에 lazy하게 시작합니다. 
    • 새로운 코루틴 테스트 API를 사용해 MainCoroutineRule을 아래의 코드로 변경했습니다.
    • 아래의 MainCoroutineRule 에서 보았듯이 기본 디스패처에 대해 UnconfinedTestDispatcher를 사용하여 코루틴을 즉시 시작합니다. 이것은 ViewModel을 테스트할 때 유용합니다. Main.immediate는 메인 스레드에서 호출될 때 프로덕션 코드에서 유사하게 즉시 동작을 합니다.

     

    @ExperimentalCoroutinesApi
    class MainCoroutineRule(val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) :
        TestWatcher() {
        override fun starting(description: Description?) {
            super.starting(description)
            Dispatchers.setMain(testDispatcher)
        }
    
        override fun finished(description: Description?) {
            super.finished(description)
            Dispatchers.resetMain()
        }
    }
    
    class HomeViewModelTest {
    
        @get:Rule
        val mainCoroutineRule = MainCoroutineRule()
    
        @Test
        fun settingMainDispatcher() = runTest {
            val viewModel = HomeViewModel()
            viewModel.loadMessage()
            assertEquals("Greetings", viewModel.message.value)
        }
    }
    
    class HomeViewModel : ViewModel() {
        private val _message = MutableStateFlow("")
        val message: StateFlow<String> get() = _message
    
        fun loadMessage() = viewModelScope.launch {
            _message.value = "Greetings"
        }
    }

     

    Lazy Scheduling

     

    하지만 어떤 경우에는 lazy 하게 스케줄링이 필요하기도 합니다.

    • 일반적으로 ViewModel의 중간 로딩 상태를 테스트하기 위한 경우입니다.
    • 이 경우 로딩 코루틴을 즉시 시작한다면 테스트는 최종 로딩 상태만 관찰할 수 있게 됩니다.
    • 이전 테스트 API에서는 pauseDispatcher를 사용해 새 코루틴이 너무 일찍 실행되는 것을 방지할 수 있었습니다.

     

    @Test
    fun loadTasks_loading() {
        // Pause dispatcher so we can verify initial values
        mainCoroutineRule.pauseDispatcher()
    
        // Load the task in the viewmodel
        statisticsViewModel.refresh()
        // Then progress indicator is shown
        assertThat(statisticsViewModel.dataLoading.getOrAwaitValue()).isTrue()
    
        // Execute pending coroutine actions
        mainCoroutineRule.resumeDispatcher()
        // Then progress indicator is hidden
        assertThat(statisticsViewModel.dataLoading.getOrAwaitValue()).isFalse()
    }

     

    • 새로운 테스트 API에서는 Main 디스패처를 StandardTestDispatcher로 설정해야 합니다. 
    • 하지만 전반적인 테스트 코드에 사용하기 위해 구현해둔 MainCoroutineRule에서의 TestDispatcher(Unconfined)와 타입이 다르기 때문에 다른 방법을 이용해야 합니다.
      • 다른 유형의 디스패처가 있는 2개의 테스트 클래스를 생성하여 각 테스트에 필요한 Rule을 사용하는 방법.
      • 기존 MainCoroutineRule 단일 클래스를 유지하며 다른 타입의 TestDispatcher가 필요한 경우 디스패처의 유형을 재정의 하는 방법.

     

    • 아래의 코드는 후자의 방법을 이용해 resumeDispatcher advanceUntilIdle로 변경했습니다.

     

    @Test
    fun loadTasks_loading() = runTest {
        // Set Main dispatcher to not run coroutines eagerly, for just this one test
        Dispatchers.setMain(StandardTestDispatcher())
    
        // Load the task in the viewmodel
        statisticsViewModel.refresh()
        // Then progress indicator is shown
        assertThat(statisticsViewModel.dataLoading.getOrAwaitValue()).isTrue()
    
        // Execute pending coroutine actions
        advanceUntilIdle()
        // Then progress indicator is hidden
        assertThat(statisticsViewModel.dataLoading.getOrAwaitValue()).isFalse()
    }

     

    Clean up code

    일부 테스트 코드에서는 코루틴이 완료되기를 명시적으로 기다려야 하는 상황이 있습니다.

    이전 테스트 API에서는 아래와 같은 코드를 추가하여 코루틴이 완료될 때까지 기다리도록 했습니다.

     

    -// Cancel is not synchronous so we need to wait for it to avoid leaks.
    -coroutineRule.testDispatcher.advanceUntilIdle()

     

    그러나 runTest는 테스트 코루틴의 하위 항목과 TestDispatcher에서 실행되는 코루틴을 포함하여 모든 코루틴을 자동으로 기다립니다. 따라서 이전에 추가했던 정리 코드를 사용할 필요가 없습니다.

     

     


    Reference

    Android에서 Kotlin 코루틴 테스트

    migrating to the new coroutines 1.6 test api

    kotlinx.coroutines

    반응형

    댓글

Designed by Me.