Android/테스팅

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

sh1mj1 2023. 10. 17. 20:16

이전 글에서 이어집니다.

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

 

안드로이드 테스트 코드를 배워보자 (1) - liveData 테스트, 비동기 테스트 기본

안드로이드 앱을 개발할 때 여러 기업에서, 프로젝트에서 테스트 코드를 작성하는 것은 중요하다고 말합니다. 아예 앱을 개발할 때 Test code 를 먼저 작성하는 경우도 있죠. 실제로 카카오에서 티

sh1mj1-log.tistory.com

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

 

안드로이드 테스트 코드를 배워보자 (2) - Room Unit Test

https://sh1mj1-log.tistory.com/175 안드로이드 테스트 코드를 배워보자 (1) - liveData 테스트, 비동기 테스트 기본 안드로이드 앱을 개발할 때 여러 기업에서, 프로젝트에서 테스트 코드를 작성하는 것은

sh1mj1-log.tistory.com

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

 

안드로이드 테스트 코드를 배워보자 (3 - 1) - runTest, TestDispatcher, 디스패처 주입

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

sh1mj1-log.tistory.com

 

음.............

바로 이전 글에서는 테스트 디스패처 `StandardTestDispatcher` 와 `UnconfinedTestDispatcher` 를 중심으로 코루틴 테스트에 대해 알아보았습니다. 

 

이번 글에서는 테스트 시 Main Dispatcher 를 다루는 법을 알아보겠습니다.

 

 

 

viewModelScope.launch 에서의 테스트

 

먼저 아래와 같은 ViewModel 과 테스트 코드가 있다고 합시다. (코틀린 공식 영상에서의 예시 코드)

 

`HomeViewModel`

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Hello World"
        }
    }
}

 

`HomeViewModelTest`

class HomeViewModelTest {
    private lateinit var viewModel: HomeViewModel    
    @Before
    fun setUp() {
        viewModel = HomeViewModel()
    }
    
    @Test
    fun testLoadMessage() = runTest {
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        Truth.assertThat("Hello World").isEqualTo(viewModel.message.value)
    }
}

 

이전 글에서의 테스트와는 달리 이번에는 `viewModelScope.launch` 내에서 `_message` 를 변경하고 있습니다.

 

이렇게 테스트를 했을 때의 결과는 아래와 같습니다.

발생한 예외 메시지를 보면 친절히 설명해주고 있네요. `Dispatchers.setMain` 을 쓰라고 이야기하고 있습니다.

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

 

`viewModelScope.launch` 는 다른 Dispatcher 가 적용되지 않으면 기본적으로 메인 스레드를 사용합니다.
그래서 테스트는 다른 스레드에서 실행되기 때문에 실패합니다.

출처:&nbsp;https://tech.kakao.com/2021/11/08/test-code/

위에서 작성한 코드의 흐름도를 보면, 파란색 줄의 테스트 스레드는 계속 흐르고 있고, `viewModelScope.launch` 에서 메인 스레드의 노란색 줄이 생겨, 각자 다른 스레드에서 동작하는 것을 알 수 있습니다

 

메인 디스패처? , `Dispatchers.setMain` , `Dispatchers.resetMain`

위에서 테스트가 실패한 이유에 대해서 조금 더 설명하겠습니다.

메인 디스패처는 UI 스레드 또는 메인 스레드에서 실행되는 코루틴 코드를 관리하기 위한 디스패처입니다. 

안드로이드 앱에서 주로 UI 업데이트나 사용자 인터페이스와 관련된 작업을 메인 스레드에서 처리해야 합니다. 그렇지 않으면 앱이 느려지거나 응답하지 않을 수도 있죠.

 

일반적으로 메인 디스패처를 사용할 때는 DI 를 통해서 주입하는 것이 좋습니다. 그러나 Jetpack ViewModel Scope 와 같은 API 는 메인 디스패처를 직접 주입하기 어려운 경우가 있습니다. 

특히, 로컬 단위 테스트에서는 Android UI 스레드를 래핑하는 메인 디스패처를 사용할 수 없습니다. 로컬 단위 테스트는 Android 기기가(device side) 아닌, 로컬 JVM(host side)에서 실행되기 때문입니다. 즉, 테스트 중인 코드가 기본 UI 스레드를 참조하면 단위 테스틍에 예외가 발생합니다.

 

이런 경우에는 메인 디스패처를 `TestDispatcher` 로 바꾸려면 `Dispatchers.setMain` 과 `Dispatchers.resetMain` 을 ㅅ용해야 합니다.

  • `Dispatchers.setMain`: 테스트 환경에서 메인 디스패처를 설정하는 메서드. 테스트 시 메인 디스패처를 목표 디스패처(예를 들어 `UnconfinedTestDispatcher`)로 설정합니다.
  • `Dispatchers.resetMain`: 메인 디스패처를 테스트 이후에 원래대로 리셋하기 위한 메서드. 테스트가 끝난 후 메인 디스패처를 초기 상태로 복원합니다.

위 두메서드를 사용하면 메인 디스패처와 관련 코드를 테스트할 때, `TestDispatcher` 로 대체할 수 있으며, 테스트가 종료될 때 메인 디스패처를 원래 상태로 복원할 수 있습니다. 

 

즉, 위 테스트를 성공하기 위해서는 아래처럼 코드를 바꾸어야 합니다.

 

`testLoadMessageUnConfined`

@Test
fun testLoadMessageUnConfined() = runTest {
    val testDispatcher = UnconfinedTestDispatcher(testScheduler)
    Dispatchers.setMain(testDispatcher)
    try {
        viewModel.loadMessage()
        Truth.assertThat("Hello World").isEqualTo(viewModel.message.value)
    } finally {
        Dispatchers.resetMain()
    }
}

 

이렇게 해주면 테스트가 성공하는 것을 확인할 수 있습니다. 위 `testLoadMessageUnConfined` 메서드에서는 `UnconfinedTestDispatcher` 를 메인 디스패처처럼 동작하도록 설정한 것입니다. 테스트 환경에서도 UI 스레드와 유사한 스레드 동작을 시뮬레이션할 수 있게 되는 것입니다.

 

그렇다면 `setUp` 함수(`@Before`)와 `teatDown` 함수(`@After`)에서 이걸 공통으로 해주면 어떨까요?

 

1차 수정한 `HomeViewModelTest`

class HomeViewModelTest2 {

    private lateinit var viewModel: HomeViewModel
    private val testDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler())


    @Before
    fun setUp() {
        viewModel = HomeViewModel()
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun testLoadMessage() = runTest{
        viewModel.loadMessage()
        Truth.assertThat("Hello World").isEqualTo(viewModel.message.value)
    }
}

 

`Dispatchers.setMain` 과 `Dispatchers.resetMain` 을 각각 `setUp`, `tearDown` 에서 수행하도록 수정했습니다.

이렇게 하면 테스트가 정상적으로 수행됩니다.

 

참고로 위 코드 에서 
`private val testDispatcher = UnconfinedTestDispatcher(testScheduler)` 라고 바로 작성하면 컴파일 오류가 발생합니다.
`testScheduler` 는 `runTest` 함수에 의해 생성된 `TestScope` 내에서 자동으로 생성되는 `TestCoroutineScheduler` 의 인스턴스이므로 `TestScope` 내에서 사용되어야 합니다.

 

테스트 디스패처를 메인 디스패처처럼 동작하도록 설정하기 전 흐름도가 아래 그림처럼 변합니다. (사진 출처는 카카오 테크 블로그 글)

왼쪽: 메인 디스패처 설정 전, 오른쪽: 메인 디스패처 설정 후.

 

MainDispatcherRule 만들기

 

그렇다면 이 ViewModel 을 사용하는 테스트들은  `Dispatchers.setMain` 이랑 `Dispacthers.resetMain` 을 `@Before` 와 `@After` 에서 계속해서 수행하겠네요. 다른 뷰모델도 마찬가지일 것입니다.

그렇다면 이런 동작을 공통적으로 수행하도록 어떤 `Rule` 로써 적용할 수 있을 것 같습니다.

 

`MainDispatcherRule`

class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

 

`TestWatcher` 를 상속받으며 기본값이 `UnconfinedTestDispatcher` 인 `TestDispatcher` 를 프로퍼티로 가지는 `MainDispatcherRule` 클래스를 생성합니다. 그리고 `starting` 과 `finished` 에서 `Dispatchers.setMain`과 `Dispatchers.resetMain` 을 수행하고 있네요.

 

2차 수정한 `HomeViewModelTest` 

class HomeViewModelTest {

    private lateinit var viewModel: HomeViewModel

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    
    @Before
    fun setUp() {
        viewModel = HomeViewModel()
    }

    @Test
    fun testLoadMessage() = runTest {
        viewModel.loadMessage()
        Truth.assertThat("Hello World").isEqualTo(viewModel.message.value)
    }
}

 

이렇게 Rule 을 설정하여 테스트가 통과하는 모습을 볼 수 있습니다.

이렇게 Rule 을 만들어 주면 다른 ViewModel 을 테스트를 할 때도 편하게 사용이 가능합니다!!

 

 

`Dispatchers.setMain` 을 호출해서 메인 디스패처를 설정하면, 해당 시점 이후에 생성된 테스트 디스패처는 먼저 메인 디스패처를 참조합니다. 만약 이미 메인 디스패처에 다른 테스트 디스패처가 설정되어 있다면나중에 생성된 테스트 디스패처는 기존 스케줄러를 공유하게 됩니다.

class HomeViewModelTest {

    private lateinit var viewModel: HomeViewModel

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Before
    fun setUp() {
        viewModel = HomeViewModel()
    }

    @Test
    fun testLoadMessage() = runTest{
        val unconfinedDispatcher = UnconfinedTestDispatcher()
        val standardDispatcher = StandardTestDispatcher()
        ,,,
    }
}

 

만약 `runTest` 를 호출하기 전에 위처럼 Rule 을 사용해서 메인 디스패처를 설정한다면, 테스트가 백그라운드에서 스케줄러를 자동으로 공유합니다. 즉, 테스트 중에 추가 테스트 디스패처를 생성하면, 해당 스케줄러는 안전하게 메인 디스패처를 참조하므로 스케줄러가 모두 공유되어 테스트가 안정적으로 실행됩니다.

 

이 말은 메인 디스패처를 설정하기 전에 테스트 디스패처를 생성한 경우에는 그 테스트 디스패처는 스케줄러를 공유하지 않는다는 말입니다.

 

테스트 바깥에서 디스패처를 만든다면?

그런데 테스트 메서드 외부에서 `TestDispatcher` 를 사용하고 싶을 수도 있습니다.

이전 글에서 `AtomicRepository` 클래스를 사용했었는데 이 클래스를 다시 가져와 봅시다.

 

`AtomicRepository`

class AtomicRepository(
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    private val scope = CoroutineScope(ioDispatcher)
    var initialized = AtomicBoolean(false)

    fun initialize() = scope.launch {
        delay(4L)
        initialized.set(true)
    }

    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}
  • I/O 디스패처로 코루틴 스코프를 생성하고 `initialzie` 메서드를 통해 새 코루틴을 시작해서 `initialzied` 를 `true` 로 함.
  • `fetchData` 메서드에서 코루틴 컨텍스트를 `Dispatchers.IO` 로 전환하여 `initialized` 값을 읽어옴.

이런 동작을 했었죠.

 

그리고 위에서 만든 `MainDispatcherRule` 을 사용하는 레포지토리 테스트를 만들어봅시다.

 

`AtomicRepositoryTestWithRule`

class AtomicRepositoryTestWithRule {
    private val atomicRepositoryDifferentScheduler = AtomicRepository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun atomicRepositoryDifferentSchedulerTest() = runTest {
        atomicRepositoryDifferentScheduler.initialize()
        advanceUntilIdle()
        Truth.assertThat(true).isEqualTo(atomicRepositoryDifferentScheduler.initialized.get())

        val data = atomicRepositoryDifferentScheduler.fetchData()
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

왼쪽: 테스트 실패한 그림. 오른쪽: 테스트가 실행되는 흐름도

위 코드에서 `atomicRepository = AtomicRepository` 를 봅시다. `Rule` 코드가 클래스 내부에 있지만 테스트 메서드 외부에서 `atomicRepository` 인스턴스가 생성되고 있습니다. 메인 디스패처는 각 테스트 메서드가 실행되기 전에만 교체됩니다.

 

그러므로 `atomicRepository` 가 만드는 테스트 디스패처는 스케줄러를 공유하지 않고 새 스케줄러를 만드는 것입니다.

이렇게 테스트 클래스의 프로퍼티로 만들어진 `TestDispatchers` 혹은 테스트 클래스에서 프로퍼티 초기화 동안에 생성된 `TestDispatchers`는 스케줄러를 공유하지 않습니다. 

 

그래서 테스트에 실패하게 되는 것입니다.

 

만약 이러한 경우에도 테스트에 스케줄러가 하나만 있도록 하려면 어떻게 해야할까요?

그런 경우에는 아래처럼 코드를 작성하면 됩니다.

 

한 스케줄러를 공유하도록 `AtomicRepositorytestWithRule`

class AtomicRepositoryTestWithRule {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val atomicRepository = AtomicRepository(mainDispatcherRule.testDispatcher)

    @Test
    fun atomicRepositoryTest() = runTest {// 메인 디스패처에서 스케줄러를 가져옴.
        // 여기서 생성된 모든 TestDispatcher도 메인에서 스케줄러를 가져옴.
        val newTestDispatcher = StandardTestDispatcher()

        atomicRepository.initialize()
        newTestDispatcher.scheduler.advanceUntilIdle() // this.advanceUntilIdle(), advanceUntilIdle 과 동일
        Truth.assertThat(true).isEqualTo(atomicRepository.initialized.get())

        val data = atomicRepository.fetchData()
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

 

 

이렇게 되면 테스트 내에서 생성된 `runTest` 와 `TestDispatcher` 는 모두 기본 디스패처의 스케줄러를 자동으로 공유합니다.

`runTest` 의 스코프를 사용하는 `this.advanceUntilIdle` 와 `newTestDispatcher.scheduler.advanceUntilIdle` 가 같은 것이죠.

만약 메인 디스패처를 `Rule` 을 사용해서 교체하지 않고서 한 스케줄러를 공유하고 싶다면 아래처럼 코드를 작성하면 됩니다.

 

`Rule` 을 사용하지 않고 한 스케줄러를 공유하도록 `AtomicRepositoryTestWithoutRule`

class AtomicRepositoryTestWithoutRule {
    // 하나의 테스트 스케줄러 생성
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = AtomicRepository(testDispatcher)
    
    @Test
    fun atomicRepositoryTest() = runTest(testDispatcher.scheduler) {
        // TestScope에서 스케줄러를 가져옴.
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // 또는 첫 번째 디스패처에서 스케줄러를 가져옴. 두 스케줄러는 같음.
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // 리포지토리 테스트...
        repository.initialize()
//        newTestDispatcher.scheduler.advanceUntilIdle()
//        anotherTestDispatcher.scheduler.advanceUntilIdle()
//        advanceUntilIdle()
        this.advanceUntilIdle() // 위 세가지 방법 모두 동일한 결과.
        Truth.assertThat(true).isEqualTo(repository.initialized.get())

        val data = repository.fetchData()
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

`newTestDispatcher` 에서는 `TestScope` 에서 스케줄러를 가져오고 있습니다.

`anotherTestDispatcher` 에서는 `AtomicRepositoryTestWithoutRule` 클래스의 프로퍼티로 설정한 `testDispatcher` 를 가지고 스케줄러를 가져오고 있습니다. 이 두 스케줄러는 서로 같습니다. 이 경우에도 같은 스케줄러를 공유하는 것입니다.

 

즉, 아래의 경우가 모두 같은 동작을 하게 됩니다.

  • `advanceUntilIdle`
  • `this.advanceUntilIdle`
  • `newTestDispatcher.scheduler.advanceUntilIdle`
  • `anotherTestDispatcher.scheduler.advanceUntilIdle`

 

자체 TestScope 만들기

`runTest` 는 내부적으로 `TestScope` 를 자동으로 만든다고 했죠? 이 스코프 외에, `runTest` 와 함께 사용할 자체 `TestScope` 를 만들고, 그런 자체 스코프에 엑세스해야 할 수도 있습니다.

 

이 때는 아래처럼 직접 만든 `TestScope` 에서 `runTest` 를 호출해야 합니다.

class SimpleExampleTest {
    val testScope = TestScope() // 기본값인 StandardTestDispatcher 생성.

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

위처럼 `TestScope` 를 직접 만드는 경우에는 테스트 내에서는 이 범위에서 `runTest` 를 호출해야 합니다. 테스트에서는 오직 `TestScope` 인스턴스 하나만 있을 수 있습니다.

`TestScope` 를 만들면 기본값인 `StandardTestDispatcher` 가 만들어지고, 새 스케줄러도 만들어집니다. 

당연히 명시적으로 아래처럼 만들 수도 있죠.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler() 
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)
    
    // ...
}

 

Scope 를 Inject 하기

만약 테스트 중에 우리가 컨트롤해야 하는 코루틴을 생성하는 클래스가 있다면, 코루틴 스코프를 해당 클래스에 주입해서 테스트에서 `TestScope` 로 대체할 수 있습니다.

 

아래 예시 코드 `UserState` 클래스를 봅시다.

 

`UserState`

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

class UserRepository { // 이전 글에서 사용한 클래스
    private var users = mutableListOf<String>()

    suspend fun register(name: String) {
        delay(100L)
        users += name
        println("Registered $name")
    }

    suspend fun getAllUsers(): List<String> {
        delay(100L)
        return users
    }
}

위 클래스는 `UserRepository` 에 의존하여, 신규 사용자를 등록하고, 등록된 사용자 목록을 가져오고 있습니다.

`UserRepository` 호출은 suspend 함수 호출입니다. 때문에 `UserState` 는 주입된 `CoroutineScope` 를 사용해서 `registerUser` 함수 내에서 새 코루틴을 시작하고 있네요.

 

`private val scope: CoroutineScope` 를 보면 스코프를 주입받도록 하고 있습니다.

 

이 클래스를 테스트하려면 `UserState` 객체를 만들 때 `runTest` 에서 `TestScope` 를 전달하면 됩니다.

 

`UserStateTest`

class UserStateTest{
    @Test
    fun addUserTest() = runTest {
        val repository = UserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // 코루틴이 완료되고 변경 사항이 전파되도록 함

        Truth.assertThat(userState.users.value).isEqualTo(listOf("Mona"))
    }
}

 

우선 여기까지 정리해야 할 것 같습니다.

이 글은 안드로이드 개발자 공식 문서와 여러 블로그 글들, 그리고 카카오 테크 블로그에서 '테스트 코드 한 줄을 작성하기까지의 고난' 이라는 글을 보면서 공부하고 있는데 카카오 글에서의 디스패처를 주입하는 부분이 조금 어려워서 다음 글에서야 정리할 수 있을 것 같습니다.

 

 

Reference 

https://www.youtube.com/watch?v=nKCsIHWircA

https://developer.android.com/kotlin/coroutines/test?hl=ko

https://lovestudycom.tistory.com/entry/Testing-Kotlin-coroutines-on-Android

https://tech.kakao.com/2021/11/08/test-code/