-
Testing Kotlin coroutines on Android프로그래밍/Kotlin 2022. 7. 19. 21:58반응형
결론
- delay()와 같이 실제 지연이 발생하면 그 지연시간을 포함해 테스트가 진행된다.
- runTest를 이용하면 delay를 무시해서 좀 더 빠르게 테스트를 실행할 수 있다.
- 단 withContext에서 Dispatcher가 변경된 경우 delay는 무시하지 않는다.
- runTest를 이용하면 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.IO 와 같은 코루틴 정식 Dispatcher 보다는 테스트용 TestDispatcher를 사용한다. (runTest를 사용하면 Dispatcher.IO를 무조건 사용하지 못한다. 안드로이드 개발자 가이드는 권고하지 않는 것 같다.)
- 테스트할 코루틴 작업 클래스는 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
반응형'프로그래밍 > Kotlin' 카테고리의 다른 글
[Kotlin new features] Sealed interface, Data object, Enum entries (0) 2023.09.16 Coroutine exceptions handling (0) 2022.06.22 Coroutine - Cancellation and timeouts (0) 2022.05.09 Making our Android Studio Apps Reactive with UI Components & Redux (0) 2020.11.08 Kotlin multiplatform 프로젝트를 생성해보자 (0) 2020.09.01 - delay()와 같이 실제 지연이 발생하면 그 지연시간을 포함해 테스트가 진행된다.