Testing Kotlin coroutines on Android
결론
- 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 변경
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