Coroutine 1.6 Test API 사용하기
kotlinx.coroutines 1.6에서 새로운 테스트 API가 도입되었으며, 이전에 쓰이던 API는 이제 더 이상 사용되지 않습니다. 이전 API를 사용하면 곧 deprecated 에러가 발생하며, 2022년 말에 완전히 제거될 예정입니다.
지난 21년 12월 말, Jetbrains에서 kotlinx.coroutines의 1.6 버전이 공개되었습니다. 멀티플랫폼을 지원하는 새로운 kotlinx-coroutines-test API이 등장했는데, 어떤 API 들이 추가되었는지, 이전 버전의 API을 쓰던 프로젝트에서는 어떻게 migration 하여 사용해야 하는지 살펴보겠습니다.
🏃🏻 runTest
새로운 API의 진입점인 runTest
코루틴 빌더를 먼저 살펴보겠습니다. 이 빌더는 이전의 runBlockingTest
를 대체하며, 모든 플랫폼에서 코루틴 코드를 테스트하는데 사용할 수 있습니다.
runTest
함수는 TestResult
를 리턴하는 함수인데, 이 값은 JVM 및 네이티브에서는 Unit
으로 나타나지만, JS에서는 Promiss
가 되며 테스트 실행 함수가 테스트가 끝날 때까지 기다리지 않는다는 사실을 반영합니다.
🕰 TestCoroutineScheduler
다음은 TestCoroutineScheduler
입니다. 이 클래스는 딜레이를 건너뛰는 동작을 제공하는 테스트에 유용하게 사용할 수 있습니다. 우리가 사용하는 TestDispatcher
는 스케줄러로 파라미터화 되는데, 여러 개의 디스패처가 동일한 스케줄러를 공유할 수 있어서 테스트 중 가상의 시간에 대한 정보가 동기화됩니다.
advanceTimeBy
함수를 통해 코루틴이 실행되는 시간을 앞당길 수도 있고, advanceUntilIdle
함수를 통해 모든 예약되어 있는 작업을 실행하거나, 가능한 빨리 실행되도록 예약되었지만 아직 디스패치 되지 않은 작업을 runCurrent
함수를 통해 실행할 수도 있습니다.
👀 TestScope
테스트 코루틴을 시작하기 위한 CoroutineScope
입니다. runTest
에서 이 인터페이스의 구체를 생성하여 테스트 스코프가 만들어지며, 이 스코프는 다음 기능을 제공합니다.
- 해당 스코프의
coroutineContext
에는 가상 시간을 조정하기 위해서TestCoroutineScheduler
를 사용하여 딜레이 건너뛰기를 지원하는 코루틴 디스패처가 포함되어 있습니다. 이 스케줄러는runTest
블록 안에서testScheduler
프로퍼티로 접근할 수 있으며, 더 편리하게 사용할 수 있도록 익스텐션 메서드가 정의되어 있습니다. (TestScope.currentTime
,TestScope.runCurrent
등등..) runTest
내부에서 이 스코프의 자식 코루틴에서 발생한 포착되지 않은 예외는 테스트가 끝날 때 보고됩니다.runTest
외부에서 자식 코루틴이 포착하지 못한 예외를throw
하는 것은 유효하지 않습니다.
vs TestCoroutineScope
TestScope
는cleanupTestCoroutines
와 동일한 기능을 따로 제공하지 않으므로runTest
가 필수적으로 호출되어야 사용할 수 있습니다.TestCoroutineScope.advanceTimeBy
는 가상 시간을 앞당긴 후TestCoroutineScheduler.runCurrent
도 호출합니다.TestCoroutineDispatcher
에서 지원했던 디스패처 일시 중지를 지원하지 않습니다. 디스패처를 일시 중지하는 대신,withContext
를 사용하여StandardTestDispatcher
(뒤에 나옵니다.)처럼 기본적으로 일시 중지되는 디스패처를 실행할 수 있습니다.- 처리되지 않은 예외에 접근할 수 없습니다.
🤝 TestDispatcher
TestDispatcher
는 TestCoroutineScheduler
에 의해 딜레이가 제어되는 CoroutineDispatcher
입니다. 2가지로 나뉩니다.
StandardTestDispatcher
: 특별한 동작이 없는 단순한 디스패처입니다. 이 디스패처는 자체적으로 작업을 실행하지 않고 항상 스케줄러에게 작업을 전달합니다. 실제로 이는launch
또는async
블록이 즉시 시작되지 않음을 의미하며 runTest 내부에서TestScope
혹은 스케줄러가 제공하는 함수를 통해 시작되도록 제어할 수 있습니다.UnconfinedTestDispatcher
:Dispatcher.Unconfined
처럼 작동하는 디스패처입니다. 코루틴이 시작되는 순서를 보장하지 않지만, 코루틴이 즉각적으로 시작되기 때문에 테스트 코드에서runCurrent
나advanceUntilIdle
같은 함수를 수동으로 호출할 필요가 없습니다.
StandardTestDispatcher vs UnconfinedTestDispatcher
Standard: 실행 순서에 대한 완전한 제어 가능, 코루틴이 자동으로 실행되지 않음
Unconfined : 실행 순서에 대한 완전한 제어 불가능, 코루틴이 자동으로 실행됨
- 코루틴의 실행 순서를 테스트해야하며, 실행되는 코루틴과 시기의 세밀한 제어가 필요하다면
StandardTestDispatcher
- 그렇지 않고 단순하고 간결한 테스트라면
UnconfinedTestDispatcher
🎲 MainDispatcherRule
기존에 우리는 Unit 테스트에서 viewModelScope
에서 사용되는 Main
디스패처인 Android UI 스레드를 사용하지 못하므로 Main
디스패처를 TestCoroutineDispatcher
로 바꿔치기 하는 Rule을 작성하여 사용했을 것입니다.
새로운 API를 사용할 때는 아래 코드처럼 변경해주면 기존의 API를 대체할 수 있으며 테스트 클래스에서 기존 처럼 사용할 수 있습니다.
😪 Lazy Start
일부 테스트에서는 Main
디스패처 코루틴에 대한 게으른 스케줄링이 필요할 수 있습니다. 이전 API에서 우리는 이런 테스트에 대해 pauseDispatcher
/resumeDispatcher
를 사용하여 새로운 코루틴이 너무 일찍 실행되는 것을 방지하곤 했습니다.
새로운 API로 동일한 테스트를 수행하려면, Main
디스패처를 StandardTestDispatcher
로 설정해야하므로 MainDispatcherRule
에서 사용하는 것과는 다른 TestDispatcher
를 지정해주어야 합니다.
따라서 우리는 runTest
블록으로 테스트 코드 본문을 감싸고, 테스트가 시작되기 전에 우리가 MainDispatcherRule
에 지정했던 UnconfinedTestDispatcher
를 StandardTestDispatcher
로 Main
디스패처를 교체해줌으로써 이전 API와 동일한 동작으로 테스트를 실행할 수 있습니다.
이 방법은 우리가 게으르게 시작되길 원하는 테스트 코드에만Dispatchers.setMain
함수를 통해 디스패처를 변경해줄 수 있습니다.
🧹 cleanup 코드 제거
우리가 작성한 테스트 중에는 테스트 내부의 코루틴이 완료되기를 명시적으로 기다리는 코드가 존재할 수 있습니다. (ex: 비동기적으로 진행되는 작업을 명시적으로 취소해주어야 하는 경우)
이전 API에서는 coroutineRule.testDispatcher.advanceUntilIdle()
함수를 마지막 줄에 추가하여 이 코루틴들의 취소 작업을 대기하도록 했지만, runTest
를 사용한다면 해당 코드를 작성할 필요가 없습니다. runTest
는 테스트 코루틴의 자식 코루틴 및 TestDispatcher
에서 실행되는 모든 코루틴의 작업을 자동으로 대기합니다. 즉, 코루틴이 완료되기를 기다리는 모든 cleanup 관련 코드를 제거할 수 있는 것입니다.