안드로이드 테스팅 (3-3) 코루틴 테스트 - viewModel 에서 디스패처 만들면?, DipatcherProvider, pauseDispatcher, resumeDispatcher
이전 글에서 이어집니다
https://sh1mj1-log.tistory.com/175
https://sh1mj1-log.tistory.com/176
https://sh1mj1-log.tistory.com/177
https://sh1mj1-log.tistory.com/178
먼저 테스트할 ViewModel 에서 디스패처를 만들 경우에 대해서부터 알아보겠습니다.
이전 글 '테스트 바깥에서 디스패처를 만든다면?' 의 연장선입니다.
ViewModel 에서 디스패처를 만든다면?
`viewModelScope.launch` 가 `CoroutineContext` 로 다른 Dispatcher 를 가진다고 합시다. 아래 코드처럼 말이죠.
`SomethingViewModel`
@HiltViewModel
class SomethingViewModel @Inject constructor() : ViewModel() {
private val _somethingEvent = MutableLiveData<String>()
val somethingEvent: LiveData<String>
get() = _somethingEvent
fun somethingMethod() {
viewModelScope.launch(Dispatchers.Default) {
delay(1000L)
_somethingEvent.value = "something"
}
}
}
`SomethingViewModelTest`
class SomethingViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: SomethingViewModel
@Before
fun setUp() {
viewModel = SomethingViewModel()
}
@Test
fun somethingTestCase() = runTest {
viewModel.somethingMethod()
advanceUntilIdle()
Truth.assertThat(viewModel.somethingEvent.value)
.isEqualTo("Hello World!")
}
}
이전 글에서 만든 `MainDispatcherRule` 은 메인 디스패처를 `testDispatcher` 로 사용할 수 있게 해주는 것이었습니다.
그런데 이제는 또 다른 `Dispatchers.Default` 가 들어가고 있습니다.
이렇게 되면 아래 그림처럼 또 다른 노란색 선으로 `Dispatchers.Default` 가 빠져나와서 `viewModelScope.launch` 블록을 실행합니다. 결과적으로 테스트는 실패하게 되죠. (사진 출처는 카카오 테크 블로그 글)
안드로이드 개발자 문서에서 'Android 코루틴 권장사항' 에서는 새 코루틴을 만들거나 `withContext`를 호출할 때 `Dispatchers` 를 하드 코딩하지 말라고 나옵니다.
// DO inject Dispatchers
class NewsRepository(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}
// DO NOT hardcode Dispatchers
class NewsRepository {
// DO NOT use Dispatchers.Default directly, inject it instead
suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}
DI 패턴을 사용하면 단위 테스트와 계측(instrumentation) 테스트의 디스패처를 testDispatcher 로 교체해서 테스트를 더 확정적으로 만들 수 있으므로, 테스트하기가 더욱 쉬워진다고 합니다.
또한 친절하게 `ViewModel`클래스의 `viewModelScope` 속성은 기본적으로 `Dispatchers.Main` 으로 하드코딩되니, 테스트에서는 `Dispatchers.setMain` 을 호출하고 테스트 디스패처를 전달해서 이를 대체하라고 하네요.
주입할 DispatcherProvider 생성
Diapatcher 를 주입하는 방법은 여러 가지가 있습니다. 일단은 아래와 같은 방식으로 해보겠습니다.
- 먼저 `DispatcherProvider` 라는 인터페이스를 만들기
- `TestDispatcherProvider` 클래스를 만들어서 어떤 `Dispatcher` 를 호출하던지, `testDispatcher` 를 사용하도록 함.
- `DefaultDispatcherProvider` 클래스를 만들어서 네이밍에 맞게 Dispatcher 를 사용하도록 함.
`DispatcherProvider`
interface DispatcherProvider {
val default: CoroutineDispatcher
val io: CoroutineDispatcher
val main: CoroutineDispatcher
}
`DefaultDispatcherProvider`
class DefaultDispatcherProvider : DispatcherProvider {
override val default: CoroutineDispatcher
get() = Dispatchers.Default
override val io: CoroutineDispatcher
get() = Dispatchers.IO
override val main: CoroutineDispatcher
get() = Dispatchers.Main
}
`TestDispatcherProvider`
class TestDispatcherProvider(
private val testDispatcher: TestDispatcher
) : DispatcherProvider {
override val default: CoroutineDispatcher
get() = testDispatcher
override val io: CoroutineDispatcher
get() = testDispatcher
override val main: CoroutineDispatcher
get() = testDispatcher
}
참고로 이 때 `TestDispatcher` 타입을 사용하기 위해서 위해서는 `test` 패키지에서 해야 합니다.
저는 `DispatcherProvider` 와 `DefaiultDispatcherProvider` 는 프로덕션 코드 패키지에,
`TestDispatcherProvider` 는 테스트 패키지에 넣었습니다.
이렇게 Provider 를 모두 만들었습니다.
Dispatcher 를 주입하자
그리고 `SomethingViewModel` 을 dispatcher 를 DI 받도록 수정해야 합니다.
`SomethingViewModel_V2`
@HiltViewModel
class SomethingViewModel_V2 @Inject constructor(
private val dispatcher: DispatcherProvider
) : ViewModel() {
private val _somethingEvent = MutableLiveData<String>()
val somethingEvent: LiveData<String>
get() = _somethingEvent
fun somethingMethod() {
viewModelScope.launch(dispatcher.default) {
delay(1000L)
_somethingEvent.value = "Hello World!"
}
}
}
이제 테스트로 돌아와서 `DispatcherProvider` 를 구현한 `TestDispatcherProvider` 구현체를 `SomethingViewModel` 에 주입해주면, `SomethingViewModel` 에서 어떤 Dispatcher 를 호출하던지 `testDispatcher` 가 호출되게 됩니다.
`SomethingViewModelTest`
class SomethingViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
// private lateinit var viewModel: SomethingViewModel
private lateinit var viewModel: SomethingViewModel_V2
@Before
fun setUp() {
val testDispatcherProvider = TestDispatcherProvider(mainDispatcherRule.testDispatcher)
viewModel = SomethingViewModel_V2(testDispatcherProvider)
}
@Test
fun somethingTestCase() = runTest {
viewModel.somethingMethod()
advanceUntilIdle()
Truth.assertThat(viewModel.somethingEvent.value)
.isEqualTo("Hello World!")
}
}
이렇게 테스트에 성공하는 것을 확인할 수 있습니다!
viewModelScope.launch 를 컨트롤하려면?
이번에는 다른 케이스를 하나 더 살펴봅시다.
`somethingViewModel`에 `progressEvent` 인스턴스 변수와 다른 메서드를 하나 더 추가해줍니다.
`SomethingViewModel_V3`
@HiltViewModel
class SomethingViewModel_V3 @Inject constructor(
private val dispatcher: DispatcherProvider
) : ViewModel() {
private val _somethingEvent = MutableLiveData<String>()
val somethingEvent: LiveData<String>
get() = _somethingEvent
private val _progressEvent = MutableLiveData<Boolean>()
val progressEvent: LiveData<Boolean>
get() = _progressEvent
fun somethingMethod3() {
_progressEvent.value = true
viewModelScope.launch {
_somethingEvent.value = "Hello World!"
_progressEvent.value = false
}
}
}
그리고 테스트는 이렇게 수행해봅시다.
`SomethingViewModelTest`
@OptIn(ExperimentalCoroutinesApi::class)
class SomethingViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: SomethingViewModel_V3
@Before
fun setUp() {
val testDispatcherProvider = TestDispatcherProvider(mainDispatcherRule.testDispatcher)
viewModel = SomethingViewModel_V3(testDispatcherProvider)
}
@Test
fun somethingTestCase3() = runTest {
viewModel.somethingMethod3()
advanceUntilIdle()
Truth.assertThat(viewModel.progressEvent.value).isEqualTo(true)
Truth.assertThat(viewModel.progressEvent.value).isEqualTo(false)
}
}
첫번째 assertion 에서 테스트가 실패하고 있습니다.
`somethingMethod3` 이 실행되면 첫번째 assert 를 확인하기 전에 `launch` 까지 다 끝나버립니다.
즉, progressEvent 는 `true` 로 변경되었다가 바로 `false` 로 변경되게 되는 것이죠. 첫번째 assert 에서는 `launch` 가 모두 끝난 후이므로 `false` 만 뜨게 됩니다.
우리는 `somethingMethod3` 에서 `launch` 가 실행되기 전에 첫번째 assert 를 하고 싶고, `launch` 의 작업이 끝난 후에 두번째 assert 를 하고 싶은 것입니다. 아래 그림처럼 말이죠.
그렇다면 이런 타이밍을 어떻게 컨트롤할 수 있을까요?
이전에는 `runBlockingTest` 의 `pauseDispatcher` 와 `resumeDispatcher` 를 사용할 수 있습니다.
카카오 테크 글에서는 아래처럼 사용하면 된다고 합니다.
하지만 `pauseDispatcher` 와 `resumeDispatcher` 는 Deprecated 되었습니다.. 그것도 Error 때문에 사용하지 말라고 하네요. (pauseDispatcher, resumeDispatcher)
코틀린 공식 사이트에서는 `StandardTestDispatcher` 처럼 기본적으로 일시중지되는 디스패처를 사용하라고 합니다.
즉, 위처럼은 해결할 수 없고 현재(2023.10.17)는 `StandardTestDispatcher` 를 사용하는 것으로만 해결 가능한 것 같습니다.
결론적으로 `SomethingViewModelTest`
class SomethingViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
private lateinit var viewModel: SomethingViewModel_V3
private lateinit var testDispatcherProvider: TestDispatcherProvider
@Before
fun setUp() {
testDispatcherProvider = TestDispatcherProvider(mainDispatcherRule.testDispatcher)
viewModel = SomethingViewModel_V3(testDispatcherProvider)
}
@Test
fun somethingTestCase3() = runTest {
viewModel.somethingMethod3()
Truth.assertThat(viewModel.progressEvent.value).isEqualTo(true)
advanceUntilIdle()
Truth.assertThat(viewModel.progressEvent.value).isEqualTo(false)
}
}
결론적으로는 위처럼 테스트 코드를 작성해야 겠네요. 이렇게 `StandardTestDispatcher` 를 `MainDispatcherRule` 에 주입해서 테스트를 통과했습니다.
이제 다음 글에서 Flow, StateFlow 을 사용하는 테스트를 알아보겠습니다. 거의 다 왔네요!~
Reference
https://tech.kakao.com/2021/11/08/test-code/
https://developer.android.com/kotlin/coroutines/coroutines-best-practices?hl=ko