프로그래밍/Kotlin

Testing Kotlin coroutines on Android

계발꿈나무 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

반응형