ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Testing Kotlin coroutines on Android
    프로그래밍/Kotlin 2022. 7. 19. 21:58
    반응형

    결론

    • delay()와 같이 실제 지연이 발생하면 그 지연시간을 포함해 테스트가 진행된다.
      • runTest를 이용하면 delay를 무시해서 좀 더 빠르게 테스트를 실행할 수 있다.
        • 단 withContext에서 Dispatcher가 변경된 경우 delay는 무시하지 않는다. 
    • 코루틴은 쓰레드에 위에서 동작하고 사용 가능한 쓰레드를 찾기 위해 코루틴용 ThreadSchdular에 스케쥴링된다. (코루틴 실행 순서와 시간에 영향)
      • 어떤 쓰레드가 사용이 될지 예측이 불가능한 Dispatcher.IO 와 같은 코루틴 정식 Dispatcher 보다는 테스트용 TestDispatcher를 사용한다. (runTest를 사용하면 Dispatcher.IO를 무조건 사용하지 못한다. 안드로이드 개발자 가이드는 권고하지 않는 것 같다.)
        • Dispatcher.IO를 사용하면 쓰레드 변경이 일어나 Thread context switching까지 테스트하게 된다. 
        • TestDispatcher는 깔끔하게 코루틴의 쓰레드 스케쥴링 테스트에 초점을 맞춘다.  
          • Dispatcher.IO는 child 코루틴들 하나당 서로 다른 쓰레드가 이용될 수 있다. 
          • TestDispatcher는 child 코루틴 하나당 단일 쓰레드의 이용이 보장된다. 
      • TestDispatcher 종류는 StandardTestDispatcher, UnconfinedTestDispatcher 두 가지가 있다.
        • 최대한 실제 환경에서 코루틴 스케쥴링을 비슷하게 맞춰 테스트하고 싶다면 StandardTestDispatcher를 사용한다.
        • 코루틴 스케쥴링 없이 실행에만 초점을 맞춰 테스트하고 싶다면 UnconfinedTestDispatcher를 사용한다.
    • 테스트할 코루틴 작업 클래스는 Dispatcher를 외부에서 주입받도록한다. 테스트 코드 작성시 TestDispatcher를 주입한다. 
    • Main Dispatcher는 Dispatchers.setMain이나 @get:Rule 어노테이션을 이용해 TestDispatcher를 등록한다.

     

    Test library 추가

    코루틴을 테스트하려면, kotlinx.coroutines.test 라이브러리를 사용해야하기 때문에 아래 의존성을 추가해야한다. 

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

     

    RunTest

    • 코루틴을 테스트하기 위해서 1.6.0 부터 추가된 runTest를 사용할 수 있다. 
    • 이 함수는 아직 Experimental 함수이고, Deprecate된 runBlockingTest를 대체할 함수이다.
    • delay()로 인한 지연현상을 테스트상에서는 무시하도록 자동으로 스킵하고 try-catch되지 않은 Exception을 대신 처리해준다.
    @Test
    fun testRunTest() = runTest {
        val currentTimeMs = System.currentTimeMillis()
        println("testRunTest")
        delay(5000)
        println("done testRunTest ${System.currentTimeMillis() - currentTimeMs} ms")
    }
    
    @Test
    fun testRunBlocking() = runBlocking {
        val currentTimeMs = System.currentTimeMillis()
        println("testRunBlocking")
        delay(5000)
        println("done testRunBlocking ${System.currentTimeMillis() - currentTimeMs} ms")
    }
    
    @Test
    fun testRunBlockingTest() = runBlockingTest {
        val currentTimeMs = System.currentTimeMillis()
        println("testRunBlockingTest")
        delay(5000)
        println("done testRunBlockingTest ${System.currentTimeMillis() - currentTimeMs} ms")
    }

    Output

    // testRunTest
    testRunTest
    done testRunTest 3 ms
    
    // testRunBlocking
    testRunBlocking
    done testRunBlocking 5005 ms
    
    // testRunBlockingTest
    testRunBlockingTest
    done testRunBlockingTest 4 ms

     

    하지만 runTest를 사용할 땐 적절한 Dispatcher를 선택해야한다.

    • TestDispatcher라는 것을 이용코루틴의 스케쥴링을 적절히 테스트해야한다. 
    • withContext와 같이 코루틴 흐름이 다른 Dispatcher로 넘어갈 때, runTest는 지연을 무시하지 않는다. 그리고 테스트들이 여러 쓰레드에서 실행되므로 예측 가능성이 떨어지게 된다. 그러므로 테스트용 TestDispatcher를 주입해야한다.
    @Test
    fun testRunTest() = runTest {
        val currentTimeMs = System.currentTimeMillis()
        println("testRunTest")
        withContext(Dispatchers.IO) {
            delay(5000)
        }
        println("done testRunTest ${System.currentTimeMillis() - currentTimeMs} ms")
    }
    
    // Outputs
    testRunTest
    done testRunTest 5018 ms

     

    TestDispatchers

    • Dispatcher는 어느 쓰레드에서 코루틴을 실행 시킬지 결정한다. 코루틴의 실행 시간과 실행 순서를 스케쥴링하기 위한 목적으로 사용된다.
    • 하나의 단위 테스트에는 하나의 Scheduler가 있어야하며 모든 생성된 TestDiapther들과 공유되어야한다. 
    • TestDispatcher는 StandardTestDispatcher, UnconfinedTestDispatcher 두 가지 구현을 가진다.
    • StandardTestDispatcher는 Child 코루틴을 바로 실행하지 않고 pause시키고 실행한다. (쓰레드 스케쥴링 O)
    • UnconfinedTestDispatcher는 Child 코루틴을 열심히 실행시킨다. (쓰레드 스케쥴링 X)
    • runTest는 StandardTestDispatcher를 기본적으로 사용한다. 

     

    StandardTestDispatcher

    새로운 코루틴을 실행하려면 테스트 쓰레드가 필요한데, 사용 가능한 테스트 쓰레드를 찾기 위해서 코루틴은 잠시 스케쥴러에 등록된다. 코루틴을 실행시키기 위해서 테스트 쓰레드가 생성되거나 재활용되고 이 일련의 스케쥴링 동작은 실제 환경과 가장 유사하게 코루틴 스케쥴링이 동작한다.

    @Test
    fun testStandardTestDispatcher() = runTest(StandardTestDispatcher()) {
        var called = false
        println("parent thread ${Thread.currentThread()}")
        val job = launch {
            println("child thread ${Thread.currentThread()}")
            called = true
        }
        // StandardTestDispatcher used by runTest() as default is not eager
        println("called1 $called")
        assertFalse(called)
    
        // join the job, or use yield(), runCurrent() or advanceUntilIdle() to
        // explicitly execute the child coroutine
        job.join()
        println("called2 $called")
        assertTrue(called)
    }

    Output

    parent thread Thread[Test worker @coroutine#1,5,main]
    called1 false
    child thread Thread[Test worker @coroutine#2,5,main]
    called2 true

     

    UnconfinedTestDispatcher

    새 코루틴이 스케쥴링 없이 현재 쓰레드나 새로운 쓰레드에서 즉시 빠르게 실행되도록 한다. 코루틴 빌더쪽의 코드가 실행되자마자 바로 실행된다. 

    @Test
    fun testUnconfinedTestDispatcher() = runTest(UnconfinedTestDispatcher()) {
        var called = false
        println("parent thread ${Thread.currentThread()}")
        val job = launch {
            println("thread ${Thread.currentThread()}")
            called = true
        }
        // StandardTestDispatcher used by runTest() as default is not eager
        println("called1 $called")
        assertTrue(called)
    
        // join the job, or use yield(), runCurrent() or advanceUntilIdle() to
        // explicitly execute the child coroutine
    //    job.join()
        println("called2 $called")
        assertTrue(called)
    }

    Output

    parent thread Thread[Test worker @coroutine#1,5,main]
    called1 true
    child thread Thread[Test worker @coroutine#2,5,main]
    called2 true

     

    정리하면, 

    • 최대한 실제 환경에서 쓰레드 스케쥴링을 비슷하게 맞춰 테스트하고 싶다면 StandardTestDispatcher를 사용한다.
    • 쓰레드 스케쥴링 없이 실행에만 초점을 맞춰 테스트하고 싶다면 UnconfinedTestDispatcher를 사용한다.

     

    TestDispatcher 주입 

    코드가 여러 쓰레드에서 동시에 실행되도록 하면 테스트가 불안정할 수 있다. 즉, 코드의 결과를 기다리는 것과 Assertion 타이밍과의 조절이 힘들어진다. 또한, 아래 이유들로 인해 안드로이드 Developer 가이드는 TestDispatcher 주입해 테스트하는 것을 권장한다. 

    • 코루틴을 동시에 여러개 테스트하고 싶을 때, 실제 코드에서 withContext와 같이 여러 Dispatcher로 테스트하고 싶을 때, 단일 쓰레드와 Scheduler를 보장해 테스트에 안정성을 기여한다.
      • Dispatchers.IO를 사용하면 실행 도중 지연이 발생하면 쓰레드 변경이 일어날 수 있다. 
      • TestDiapatcher는 지연이 발생해도 쓰레드 변경이 일어나지 않는다. 온전히 코루틴 스케쥴링을 테스트한다. 
    • 새 코루틴의 스케쥴러 등록과 실행 순서를 제어할 수 있다.
      • ex) 서로 순서의 영향이 있는 테스트할 때, 코루틴1보다 코루틴2가 먼저 실행되었을 때 테스트 등
    • Delay를 임의로 건너뛰어 테스트를 시간을 앞당길 수 있다. 

     

    아래 Repository 클래스에서 Dispatcher를 주입받도록 할 수 있다. 

    // Example class demonstrating dispatcher use cases
    class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
        private val scope = CoroutineScope(ioDispatcher)
        val initialized = AtomicBoolean(false)
    
        // A function that starts a new coroutine on the IO dispatcher
        fun initialize() {
            scope.launch {
                initialized.set(true)
            }
        }
    
        // A suspending function that switches to the IO dispatcher
        suspend fun fetchData(): String = withContext(ioDispatcher) {
            require(initialized.get()) { "Repository should be initialized first" }
            delay(500L)
            "Hello world"
        }
    }

    부모 코루틴과 Scheduler를 공유하고 StandardTestDispatcher를 생성해서 Repository에 주입한다. 테스트코드에서 initilize()를 먼저 실행되도록 하고 fetchData()를 호출하면, delay도 스킵되어 빠르게 실행할 수 있다. 

    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)
    
        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())
    
        val data = repository.fetchData() // No thread switch(Dispatchers.IO를 사용하면 delay로 thread 변경일어남), delay is skipped
        assertEquals("Hello world", data)
    }

    하지만 initialize에 대한 부분은 실제 코드에서 async 빌더로 변경하는 것이 더 좋다. 

    fun initialize() {
        scope.async {
            initialized.set(true)
        }
    }
    
    @Test
    fun repoInitWorks() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = BetterRepository(dispatcher)
    
        repository.initialize().await() // Suspends until the new coroutine is done
        assertEquals(true, repository.initialized.get())
        // ...
    }

     

    Main Dispatcher 변경

    단위 테스트에서는 JVM에서 실행되기 때문에 Android Main Dispatcher의 UI Thread를 사용할 수 없다.
    아래 뷰모델 코드가 있다고 했을 때, 
    class HomeViewModel : ViewModel() {
        private val _message = MutableStateFlow("")
        val message: StateFlow<String> get() = _message
    
        fun loadMessage() {
            viewModelScope.launch {
                _message.value = "Greetings!"
            }
        }
    }

    단위 테스트에서 실행해보면, 아래와 같은 Exception이 발생한다.

    @Test
    fun testMainDispatcher() = runTest {
        val homeViewModel = HomeViewModel()
        homeViewModel.loadMessage()
    }
    
    // Output
    Exception in thread "Test worker @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
    	at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:118)
    	at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:96)
    	at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:319)
    	at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:30)
    	at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:25)
    	at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:110)
    	at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:126)
    	at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56)
    	at kotlinx.coroutines.BuildersKt.launch(Unknown Source)
    	at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:47)
    	at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source)
    	at com.example.largescreen.HomeViewModel.loadMessage(HomeViewModel.kt:14)

    그래서 모든 경우에 대해 Main Dispatcher를 TestDispatcher로 변경하려면 아래와 같이 Dispatchers.setMain() 을 이용해 변경해준다.

    class HomeViewModelTest {
        @Test
        fun settingMainDispatcher() = runTest {
            val testDispatcher = UnconfinedTestDispatcher(testScheduler)
            Dispatchers.setMain(testDispatcher)
    
            try {
                val viewModel = HomeViewModel()
                viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
                assertEquals("Greetings!", viewModel.message.value)
            } finally {
                Dispatchers.resetMain()
            }
        }
    }

    다음과 같이 단위 테스트 클래스에 전체 적용되도록 JUnit 규칙으로 추가할수도 있다.

    MainDispatcherRule이라는 클래스를 생성한다. 

    class MainDispatcherRule(
        val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), // Scheduler를 새로 생성한다. 
    ) : TestWatcher() {
        override fun starting(description: Description) {
            Dispatchers.setMain(testDispatcher)
        }
    
        override fun finished(description: Description) {
            Dispatchers.resetMain()
        }
    }

    @get:Rule 어노테이션을 사용한다.

    class HomeViewModelTestUsingRule {
        @get:Rule
        val mainDispatcherRule = MainDispatcherRule()
    
        @Test
        fun settingMainDispatcher() = runTest { // Uses Main’s scheduler (Main의 Scheduler가 자동으로 공유된다)
            val viewModel = HomeViewModel()
            viewModel.loadMessage()
            assertEquals("Greetings!", viewModel.message.value)
        }
    }

    다음과 같이 서로 다른 Main Dispatcher와 새로 생성한 StandardTestDispatcher 를 넘겨 테스트할수도 있다.

    class DispatcherTypesTest {
        @get:Rule
        val mainDispatcherRule = MainDispatcherRule()
    
        @Test
        fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
            // Use the UnconfinedTestDispatcher from the Main dispatcher
            val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)
    
            // Create a new StandardTestDispatcher (마찬가지로 Main의 Scheduler가 공유된다.)
            val standardRepo = Repository(StandardTestDispatcher())
        }
    }

    주의해야할 점은 아래와 같이 Main Dispatcher가 변경되기 전에 만들어진 Dispatcher는 Scheduler를 공유하지 않게 된다. 

    class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }
    
    class RepositoryTestWithRule {
        private val repository = Repository(/* What TestDispatcher? */)
    
        @get:Rule
        val mainDispatcherRule = MainDispatcherRule()
    ...
    }

    따라서 Main Dispatcher가 변경된 이후에 Scheduler를 넘겨줘야한다.

    class RepositoryTestWithRule {
        @get:Rule
        val mainDispatcherRule = MainDispatcherRule()
    
        private val repository = Repository(mainDispatcherRule.testDispatcher)
        ...

    Main Dispatcher를 새로 변경하지 않고 새로 Dispatcher를 필드로 생성하려고 한다면 다음과 같이 Schduler를 넘겨주어야 한다.

    class RepositoryTest {
        // Creates the single test scheduler
        private val testDispatcher = UnconfinedTestDispatcher()
        private val repository = Repository(testDispatcher)
    
        @Test
        fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
            // Take the scheduler from the TestScope
            val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
            // Or take the scheduler from the first dispatcher, they’re the same
            val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)
    
            // Test the repository...
        }
    }

     

    TestScope

    Schduler와 Dispatcher를 직접 생성하고 싶다면, TestScope을 이용하면 된다.

    class ExampleTest {
        val testScheduler = TestCoroutineScheduler()
        val testDispatcher = StandardTestDispatcher(testScheduler)
        val testScope = TestScope(testDispatcher)
    
        @Test
        fun someTest() = testScope.runTest {
            // ...
        }
    }

     

    Dispatchers.IO VS TestDiapatcher

    • Dispatchers.IO를 사용하면 실행 도중 지연이 발생하면 쓰레드 변경이 일어날 수 있다. 
      • Thread pool에 있는 Thread간 Context switching이 발생한다.  
    • TestDiapatcher는 지연이 발생해도 쓰레드 변경이 일어나지 않는다
      • delay될일도 없으니 단일 쓰레드가 이용된다. 부모 코루틴이 쓰레드를 놔주지 않으면 새로운 쓰레드가 생성되어 사용된다. 
    @Test
    fun testStandardTestDispatcherChildren() =
        runTest(UnconfinedTestDispatcher()/*StandardTestDispatcher*/) {
            repeat(10) {
                launch {
                    println("Thread$it ${Thread.currentThread()}")
                    delay((1000L + Math.random() * 10L).toLong())
                    println("Thread$it ${Thread.currentThread()}")
                }
            }
        }
    // Output
    /*
    Test worker: 쓰레드 이름, @coroutine#2,5,main: 코루틴 이름
    Test worker라는 하나의 쓰레드만 실행됨
    Thread1 Thread[Test worker @coroutine#2,5,main]
    Thread2 Thread[Test worker @coroutine#3,5,main]
    Thread3 Thread[Test worker @coroutine#4,5,main]
    Thread4 Thread[Test worker @coroutine#5,5,main]
    Thread5 Thread[Test worker @coroutine#6,5,main]
    Thread6 Thread[Test worker @coroutine#7,5,main]
    Thread7 Thread[Test worker @coroutine#8,5,main]
    Thread8 Thread[Test worker @coroutine#9,5,main]
    Thread9 Thread[Test worker @coroutine#10,5,main]
    Thread10 Thread[Test worker @coroutine#11,5,main]
    Thread7 Thread[Test worker @coroutine#8,5,main]
    Thread8 Thread[Test worker @coroutine#9,5,main]
    Thread4 Thread[Test worker @coroutine#5,5,main]
    Thread10 Thread[Test worker @coroutine#11,5,main]
    Thread5 Thread[Test worker @coroutine#6,5,main]
    Thread9 Thread[Test worker @coroutine#10,5,main]
    Thread2 Thread[Test worker @coroutine#3,5,main]
    Thread3 Thread[Test worker @coroutine#4,5,main]
    Thread1 Thread[Test worker @coroutine#2,5,main]
    Thread6 Thread[Test worker @coroutine#7,5,main]
    */
    
    @Test
    fun testDispatchersIoChildren() = runBlocking(Dispatchers.IO) {
        repeat(10) {
            launch {
                println("Thread$it ${Thread.currentThread()}")
                delay((1000L + Math.random() * 10L).toLong())
                println("Thread$it ${Thread.currentThread()}")
            }
        }
    }
    // Output
    /*
    DefaultDispatcher-worker-3: 쓰레드 이름, @coroutine#2,5,main 코루틴 이름
    DefaultDispatcher-worker-* 번호만 바뀌면서 다른 쓰레드들이 실행됨
    Thread1 Thread[DefaultDispatcher-worker-3 @coroutine#2,5,main]
    Thread2 Thread[DefaultDispatcher-worker-1 @coroutine#3,5,main]
    Thread3 Thread[DefaultDispatcher-worker-4 @coroutine#4,5,main]
    Thread4 Thread[DefaultDispatcher-worker-7 @coroutine#5,5,main]
    Thread5 Thread[DefaultDispatcher-worker-6 @coroutine#6,5,main]
    Thread2 Thread[DefaultDispatcher-worker-7 @coroutine#3,5,main]
    Thread5 Thread[DefaultDispatcher-worker-3 @coroutine#6,5,main]
    Thread3 Thread[DefaultDispatcher-worker-4 @coroutine#4,5,main]
    Thread4 Thread[DefaultDispatcher-worker-6 @coroutine#5,5,main]
    Thread1 Thread[DefaultDispatcher-worker-6 @coroutine#2,5,main]
    */

     

     

    https://www.youtube.com/watch?v=Xh9Nt7y07FE 

    https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md

    https://developer.android.com/kotlin/coroutines/test

    https://medium.com/@ralf.stuckert/testing-coroutines-update-1-6-0-701d53546683

    반응형
Designed by Tistory.