Android/테스팅

안드로이드 테스팅 (3-3) 코루틴 테스트 - viewModel 에서 디스패처 만들면?, DipatcherProvider, pauseDispatcher, resumeDispatcher

sh1mj1 2023. 10. 17. 22:36

이전 글에서 이어집니다

https://sh1mj1-log.tistory.com/175

https://sh1mj1-log.tistory.com/176

https://sh1mj1-log.tistory.com/177

https://sh1mj1-log.tistory.com/178

 

안드로이드 테스트 코드를 배워보자 (3 - 2) - viewModelScope.launch 에서 테스트, 메인 디스패처 설정,

이전 글에서 이어집니다. https://sh1mj1-log.tistory.com/175 안드로이드 테스트 코드를 배워보자 (1) - liveData 테스트, 비동기 테스트 기본 안드로이드 앱을 개발할 때 여러 기업에서, 프로젝트에서 테스

sh1mj1-log.tistory.com

 

흠... (2트)

 

먼저 테스트할 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!")
    }
}

 

이렇게 테스트에 성공하는 것을 확인할 수 있습니다!

왼쪽: 앞에서 다른 Dispatcher 를 넣었을 때 흐름도, 오른쪽: Dispatcher 를 주입받을 수 있게 만들고 테스트에서 testDispatcher 를 주입받았을 때 흐름도

 

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)

 

`pauseDispatcher` 와 `resumeDispatcher` 가 Deprecated 되었다..

코틀린 공식 사이트에서는 `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

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/pause-dispatcher.html

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-delay-controller/resume-dispatcher.html