Android/테스팅

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

sh1mj1 2023. 10. 13. 18:38

이전 글에서 이어집니다.

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

 

아오 어려워 코루틴 테스트시치

 

코루틴 테스트 이전에 Rxjava 테스트를 공부하고 코루틴 테스트로 넘어가려고 했는데, RxJava 는 도저히 모르겠네요.. 

나중에 RxJava 를 다시 공부하고 나서 해야겠습니다.

그런데 코루틴 테스트도 어려움. (난 어려움)

 

의존성 추가

테스트 코드를 작성하기 앞서 코루틴 관련 의존성을 추가해줍시다.

모듈의 빌드 그래들 의존성 추가 (코루틴 테스트 공식 문서)

 

// Coroutines
def coroutines_version = "1.6.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"

// Coroutines Test
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"

 

 

간단한 샘플 코드 보기

테스트에서 `suspend` 함수를 호출하려면 코루틴이 있어야 합니다. JUnit 테스트 함수 자체는 `suspend` 함수가 아니므로 테스트 내에서 코루틴 빌더를 호출하여 새 코루틴을 시작해야 합니다. 

 

아래 코드 예를 보시죠.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() {
    val data = fetchData()
    assertEquals("Hello world", data)
}

`suspend` 함수를 `@Test` 함수에서 그대로 사용하려고 하고 있네요. 이것은 당연히 오류를 보입니다.

왼쪽: suspend 함수를 테스트 함수에서 그대로 썻을 때.  오른쪽: 테스트 함수를 suspend 로 만들었을 때

오류 메시지도 친절히 나오고 있습니다. 

그렇다고 `dataShouldBeHelloWorld` 라는 함수를 `suspend` 로 만들어서도 안됩니다. 그러면 오류는 발생하지 않지만 실행하더라도 `Test events were not received` 라는 것만 뜨며 테스트가 수행되지 않습니다. 

 

코틀린의 테스트 함수는 일반적으로 동기 함수로 작성되어야 하며, 코루틴 블록은 테스트 함수 내에 정의해야 합니다.

 

runBlocking

이전 글을 읽으셨다면 아시겠지만, `runBlocking` 이라는 코드를 사용하여 `CoroutineScope` 를 만들어 주어서 테스트를 실행시킬 수 있습니다.  `dataShouldBeHelloWorld` 를 아래처럼 간단히 변경하면 됩니다.

@Test
fun dataShouldBeHelloWorld() = runBlocking {
    val data = fetchData()
    Truth.assertThat("Hello world").isEqualTo(data)
}

 

결과는 `fetchData` 함수 내에서 `delay` 를 1초 동안 하기 때문에 테스트가 수행되는 시간은 1초가 넘게 걸립니다.

 

`runBlocking` 은 테스트 함수 내에서 사용될 때 테스트를 순차적으로 진행합니다. 동기 테스트 코드를 작성할 때 사용하는 것이죠. 따라서 `delay` 를 1초를 건다면 1초를 실제로 기다리게 됩니다.

 

runTest

하지만 `runTest` 를 사용해볼까요?

`runTest` 는 `kotlinx.coroutines.1.6`  에서 새롭게 도입된 테스트 API 입니다. 21년 12월 말 코틀린 1.6 버전에서 추가되었습니다.

이전 코드에서 `dataShouldBeHelloWorld`  을 아래처럼 간단히 바꾸면 됩니다.

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    Truth.assertThat("Hello world").isEqualTo(data)
}

 

이렇게 되면 테스트 시간이 54ms 밖에 걸리지 않습니다! 

 

`runTest` 는 비동기 코루틴 코드를 테스트하는데 사용되며, 테스트를 병렬로 실행합니다. 그래서 테스트 코드를 `runTest` 로 래핑하면, `suspend` 함수를 테스트할 수 있고, 코루틴의 지연을 자동으로 건너뛰므로 위 테스트가 1초보다 훨씬 빠르게 완료됩니다.

 

문제점 - 비동기 동작이 다른 스레드로 이동할 때

 

만약 코드가 코루틴 실행을 `withContext` 를 사용하는 등의 방법으로 다른 쓰레드로 이동한다고 합시다.

class SuspendingFuncTest {
    suspend fun fetchData(): String = withContext(Dispatchers.IO) {
        delay(1000L)
        "Hello world"
    }

    @Test
    fun dataShouldBeHelloWorld() = runTest {
        val data = fetchData()
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

위처럼 코드를 변경하면 코루틴이 `Dispatchers.IO` 의 쓰레드에서 실행됩니다.

왼쪽: 테스트 결과, 오른쪽: 코드 흐름 그림

이 경우 테스트는 1초가 넘습니다.  `runTest` 는 계속 작동하지만 지연을 건너뛰지 않습니다.

이것이 문제가 되죠. 위 오른쪽 그림처럼 `Dispatchers.IO` 에서 `delay` 가 됩니다. 하지만 우리는 테스트에서는 정말로 1초 `delay` 되는 걸 원치 않습니다.

 

그 밖에도 테스트에서 main task 루틴 밖에 새로운 코루틴을 만든다면 테스트가 복잡해집니다.

코드가 여러 스레드에서 실행되므로 테스트를 예측하기 어려워 지죠. 이러한 이유로, 테스트에서 실제 Dispatcher 를 교체하려면 TestDispatcher 를 주입해야 합니다.

 

`StandardTestDispatcher` 에서 새 코루틴을 시작하면 코루틴이 기본 스케줄러의 대기열에 추가되고 테스트 스레드를 사용할 수 있을 때마다 실행됩니다. 이러한 새 코루틴이 실행되도록 하려면 테스트 스레드를 생성해야 합니다(다른 코루틴에서 사용할 수 있도록 해제).

이러한 대기열에 추가되는 동작을 통해 테스트 중에 새 코루틴이 실행되는 방식을 정밀하게 제어할 수 있으며 이 동작은 프로덕션 코드의 코루틴 예약과 유사합니다.

 

문제점 - 테스트에서 새로운 코루틴을 생성하는 경우

아래 예시 코드를 봅시다. (코드 출처)

 

`UserRepository`

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
    }
}

 

`StandardTestDispathcerTest`

class StandardTestDispatcherTest {
    @Test
    fun standardTest() = runTest {
        val userRepo = UserRepository()

        launch { userRepo.register("Alice") }
        launch { userRepo.register("Bob") }

        Truth.assertThat(listOf("Alice", "Bob")).isEqualTo(userRepo.getAllUsers())
    }
}

위 코드는 `UserRepository`객체를 생성한 다음 두 개의 새로운 코루틴을 사용하여 해당 저장소에 두 명의 사용자를 등록하는 테스트입니다.

테스트 함수의 바디에서 `launch` 를 호출해서 새로운 코루틴을 두개 만들고 있죠

 

이 테스트 코드를 실행한 결과는 아래처럼 나옵니다.

어쩐 일에선지 `userRepo` 에 정상적으로 Alice 와 Bob 이 추가되지 않는 모습이 보입니다.

 

왜 이렇게 테스트에 실패할까요? 이 이유는 아래 TestDispatcher 부분에서 확인하실 수 있습니다.

바로 `TestDispatcher` 에 대해 알아보죠!

 

TestDispatcher

`runTest` 는 테스트를 위한 코루틴을 만들어주는 특별한 coroutine Builder 입니다. 

모든 코루틴은 코루틴 스코프 안에서 실행되어야 하듯이 `runTest`는 `TestScope` 를 만듭니다.

`TestScope`는 항상 안에서 `TestDispatcher` 를 사용합니다.  그리고 `TestDispatcher` 는 `TestCoroutineScheduler` 에 의존합니다. 

`TestCoroutineScheduler` 는 테스팅 라이브러리의 코어입니다. 구현의 대부분이 실제로 일어나는 곳이죠.

 

그러니까, `runTest` 가 `TestScope` 를 만들고 스코프는 `TestDispatcher` 을 자동, 혹은 수동으로 만듭니다. 

추가적인 `TestDispatcher` 를 개발자가 테스트하는 중에 필요하다면 만들 수도 있습니다.

그런데 여기서 중요한 점은 테스트 디스패처들이 항상 같은 스케줄러를 공유해야 한다는 것입니다!!!

 

세 디스패처를 인스턴스화할 때 전체 테스트 내에 단 하나의 `TestcoroutineScheduler` 인스턴스만 있어야 합니다.

 

 

테스트 디스패처의 구현체는 `StandardTestDispatcher` 와 `UnconfinedTestDispatcher` 가 있습니다.

이 두가지 테스트 디스패처를 알아봅시다.

 

StandardTestDispatcher

`StandardTestDispatcher` 는 따로 지정하지 않으면 디폴트로 선택됩니다. 이 경우 테스트 스코프(`runTest`) 내에서 시작하는 모든 코루틴을 스케줄러에 큐잉합니다. 이 코루틴들은 스레드에서 코루틴을 실행할 시간이 있을 때 혹은 개발자가 직접 실행을 앞당길 때 실행됩니다.

 

먼저 `runTest` 는 `StandardTestDispatcher` 를 기본으로 사용합니다.

 

그러면 다시 `StandardTestDispatcherTest` 코드를 봅시다.

class StandardTestDispatcherTest {
    @Test
    fun standardTest() = runTest {
        val userRepo = UserRepository()

        launch { userRepo.register("Alice") }
        launch { userRepo.register("Bob") }

        Truth.assertThat(listOf("Alice", "Bob")).isEqualTo(userRepo.getAllUsers())
    }
}

여기서 `launch` 는 람다의 receiver 에서 호출되고 있습니다. 사실은 `CoroutineScope.launch`  의 형태로 호출되어야 하고, 여기서 `runTest` 가 `TestScope` 를 만들기 때문에 `this.launch` 에서  `this` 가 생략된 채로 실행되고 있는 것입니다.

그리고 따로 디스패처 관련 코드가 없으니 디폴트 `TestDispatcher` 인  `StandardTestDispatcher` 가 사용되고 있습니다.

 

즉, 현재 이 테스트 함수는 Test Thread 에서 실행되어서 `userRepo` 를 만들고, `launch` 에서 새로운 코루틴을 실행합니다. 이 새로운 코루틴은 `StandardTestDispatcher` 에서 스케줄러로 큐잉하며 해당 코루틴 내의 동작이 완료되지 않고 리턴합니다. 그리고 다음 `launch` 도 실행되는 겁니다.

 

`standardTest` 함수가 테스트 스레드에서 실행되는 모습

결국 assertion 이 `launch` 내에 있는 동작보다 더 먼저 실행되기 때문에 테스트가 실패했던 것입니다

그렇다면 테스트 통과를 위해서 우리가 수동으로 `launch` 의 코루틴들을 앞당겨야 합니다.

 

결국 이러한 문제 때문에 발생했던 문제였습니다!

그렇다면 쉽게 고쳐볼까요?

 

수동으로 코루틴들을 앞당길 수 있는 세 가지 함수가 있습니다.

  • `runCurrent`: 현재(가상) 시간에 실행되도록 스케줄된 코루틴에 가서 작업을 실행. 
  • `advanceTimeBy(delayTimeMills: Long): 주어진 양만큼 (가상)시간을 보내고 시간의 해당 지점 전에 실행되도록 스케줄링된 것들을 실행.
  • `advanceUntilIdle`: 큐잉된 것들 중 남은 것이 없을 때까지 스케줄러에서 다른 코루틴을 모두 실행. (대기 중인 코루틴을 모두 실행하도록함)

왼쪽부터 차례대로 `runCurrent`, `advanceTimeBy(100), `advanceUntilIdle`

 

변경된 `StandardTestDispatcherTest`

@Test
fun standardTest() = runTest {
    val userRepo = UserRepository()

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle()

    Truth.assertThat(listOf("Alice", "Bob")).isEqualTo(userRepo.getAllUsers())
}

 

이렇게 `launch` 와 assertion 사이에 `advanceUntilIdle` 을 수행하게 되면 우리가 의도한 대로 테스트가 수행되는 모습을 볼 수 있습니다. 

왼쪽: 수행되는 순서 상상도. 오른쪽 테스트 결과

 

그렇다면 이제 `UnconfinedTestDispatcher` 에 대해 알아봅시다.

UnconfinedTestDispatcher

`UnconfinedTestDispatcher`  는 크게 세가지 키워드가 중요합니다.

  • 새 코루틴을 'eagerly' 하게 시작함.즉, 새 코루틴을 시작할 때 대기하지 않고 바로 실행됨.
  • 간단한 테스트에 자주 사용함.
  • 실제로 동시성을 띄지  않는다. [중요!]

 

아래처럼 코드를 작성합니다.

 

`UnconfinedTestDispatcherTest`

class UnconfinedTestDispatcherTest {
    @Test
    fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {
        val userRepo = UserRepository()

        launch { userRepo.register("Alice") }
        launch { userRepo.register("Bob") }

        Truth.assertThat(listOf("Alice", "Bob")).isEqualTo(userRepo.getAllUsers())
    }
}

 

코드에서 `runTest` 의 패러미터로 `UnconfinedTestDispatcher` 인스턴스를 만들어서 넣고 테스트를 실행하고 있습니다.

전달된 `UnconfinedTestDispatcher` 는  `runTest` 가 생성하는 `TestScope` 에서 사용됩니다. 그리고 테스트 내에서 호출되는 `launch` 함수들은 해당 `UnconfinedTestDispatcher 를 상속받아서 실행됩니다. 

 

즉, `launch`호출들은 `UnconfinedTestDispatcher` 를 사용하여 실행되고 `UnconfinedTestDispatcher` 의 특성에 따라서 즉시 실행됩니다.

 

이 테스트를 실행하면 아래처럼 결과가 나옵니다.

 

왼쪽: 테스트 결과, 오른쪽: 실행 순서 상상도

 

 

이렇게 `TestDispatcher` 를 간단히 정리하면 아래 정도가 되겠네요.

 

`StandardTestDispatcher` 는 새로운 코루틴들을 테스트 코루틴 스케줄러에 큐잉하며 default 로 사용한다.

`UnconfinedTestDispatcher` 는 새로운 코루틴을 즉시 시작하며, 메인 디스패처로 사용할 때 특별한 경우에만 선택적으로 사용해야 한다. `Flow` 를 수집하는 코루틴의 동작을 테스트, 디버깅할 때 사용된다.

 

TestDispatcher 를 Injecting(주입) 하기

위에서 말했듯이 코루틴 테스팅에서 디스패처를 주입하여 테스팅 가능한 코드를 작성할 수 있습니다.

 

이번에는 이런 예를 봅시다. (코루틴 테스트 관련 공식 영상에서의 코드 참조)

 

`MyRepository`

class MyRepository(private val database: IDatabase) {
    private val scope = CoroutineScope(Dispatchers.IO)

    fun initialize() {
        scope.launch {
            database.populate()
        }
    }

    suspend fun fetchData(): String = withContext(Dispatchers.IO) {
        database.read()
    }
}

 

위 `MyRepository` 에서는

  • 임의로 만든 어떤 `IDatabase` 인터페이스에 의존하여 I/O 디스패처를 사용함.
  • I/O 디스패처로 코루틴 스코프를 생성하고, `initialize` 메서드를 통해 새로운 코루틴을 시작하여 DB 를 초기화함.
  • `fetchData` 메서드에서는 코루틴 컨텍스트를 `Dispatchers.IO` 로 전환하여 데이터베이스에서 데이터를 읽어옴.

 

위 코드는 데이터베이스를 가짜로 만들어서 사용하는 것이므로 실제 테스트에 사용할 코드는 아래 `AtomicRepository` 를 사용하겠습니다.

 

`AtomicRepository`

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

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

    suspend fun fetchData(): String = withContext(Dispatchers.IO) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

 

  • I/O 디스패처로 코루틴 스코프를 생성하고 `initialzie` 메서드를 통해 새 코루틴을 시작해서 `initialzied` 를 true 로 함.
  • `fetchData` 메서드에서 코루틴 컨텍스트를 `Dispatchers.IO` 로 전환하여 `initialized` 값을 읽어옴.

 

위 `MyRepository`를 테스트하는 아래와 같은 테스트 코드가 있다고 합시다.

 

`MyRepositoryTest`

class MyRepositoryTest {

    @Test
    fun repoTest() = runTest {
        val repository = MyRepository(FakeDatabase())
        repository.initialize()

        val data = repository.fetchData()

        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

위 테스트 코드에서는 

  • `MyRepository` 인스턴스를 만들고 `FakeDatabase` 라는 구현체를 주입함.
  • `initialize` 메서드를 호출해서 데이터를 설정함. IO 디스패처의 스레드의 새로운 코루틴에서 `populate` 메서드가 실행됨.
  • `fetchData` 메서드를 호출하여 데이터를 읽어옴.  IO 디스패처의 스레드에서 `read` 메서드가 실행됨.
  • assertion 에서 데이터를 확인함.

역시 실제 테스트는 아래 `AtomicRepositoryTest` 를 사용하겠습니다.

 

`AtomicRepositoryTest`

class AtomicRepositoryTest {

    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val repository = AtomicRepository()
        repository.initialize()
        advanceUntilIdle()

        Truth.assertThat(true).isEqualTo(repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

 

여기서 `MyRepositoryTest` 와 `AtomicRepositoryTest` 의 테스트를 수행한 결과 테스트가 통과되는 경우도 있고, 통과되지 않는 경우도 있습니다.

 

왼쪽: 모든 테스트가 통과된 모습. 오른쪽 initialized 에 대해 테스트했을 때 통과되지 않은 모습

🤔 왜 `advanceUntilIdle()` 까지 호출했는데 테스트가 실패하지?

 

사실은 당연한 결과입니다. 왜냐면 `advanceUntilIdle` 메서드는 테스트 스레드의 테스트 스코프에서 스케줄러에 큐잉된 것들을 처리해주는 것이기 때문이죠. 이것에 대해 헷갈리면 위 쪽 (문제점 - 비동기 동작이 다른 스레드로 이동할 때) 에서의 그림을 보면 됩니다.

 

아래 그림을 보면 알 수 있듯이 `initialize` 메서드를 통해 `initialzied` 변경을 수행하는 것은 테스트 스레드가 아닌, IO 스레드에서 일어났습니다.

그러므로 위 동작은 아래 그림처럼 동작될 수 있겠죠.

위처럼만 동작하게 된다면 문제없이 assertion 이 수행되어 테스트가 통과합니다. 하지만 위 경우는 이상적인(운이 좋은) 경우이고, 위 코드는 아래 그림처럼 문제가 될 수도 있습니다.

 

 

위 그림처럼 `initialize` 메서드에서 시작한 코루틴의 `populate` 메서드의 작업이 끝나지 않았는데 `fetchData` 메서드의 `read` 가 수행될 수도 있습니다. 즉, 데이터베이스가 아직 초기화되지 않은 상태에서 데이터를 읽게 될 수도 있죠. 이렇게 되면 우리가 원하는 테스트가 진행되지 않습니다. 위 그림에서 그 모습을 쉽게 보여주고 있습니다.

 

그렇다면 이 문제는 어떻게 해결할 수 있을까요???

 

바로 `MyRepository` 클래스 내부에서 I/O 디스패처를 직접 사용하는 대신에 I/O Dispatcher 를 코루틴 디스패처로 주입하는 방식으로 바꾸면 됩니다.

 

디스패처를 주입하는 것으로 `MyRepository` 변경

class MyRepository(
    private val database: IDatabase,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO // 변경
) {
    private val scope = CoroutineScope(ioDispatcher)  // 변경

    fun initialize() {
        scope.launch {
            database.populate()
        }
    }

    suspend fun fetchData(): String = withContext(ioDispatcher) {// 변경
        database.read()
    }
}

이런식으로 `initialize` 메서드와 `fetchData` 메서드 모두 클래스에 주입된 `ioDispatcher` 를 사용합니다. 

그리고 테스트에서는 모의 디스패처를 사용하여 테스트 스코프 내에서 코루틴 실행을 제어합니다.

 

그렇다면 테스트 코드는 어떻게 바꾸면 될까요?

`MyRepository` 인스턴스를 만들 때 우리가 원하는 순서대로 동작하도록 `StandardTestDispatcher` 를 주입해주면 됩니다.

 

디스패처를 주입하도록 변경된 `MyRepositoryTest`

class MyRepositoryTest {

    @Test
    fun repoTest() = runTest {
        val repository = MyRepository(
            FakeDatabase(),
            ioDispatcher = StandardTestDispatcher(testScheduler) // 주입
        )
        repository.initialize()
        advanceUntilIdle()
        
        val data = repository.fetchData()
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

 

이렇게 되면 `TestCoroutineScheduler`에 먼저 `db.populate()` 메서드가 큐잉되고, `advanceUntilIdle()`  에 의해 해당 코루틴을 모두 종료시킨 후, `db.read()` 를 실행하고, assertion 을 합니다. 아래 그림과 같죠.

왼쪽 : 스케줄러에 코루틴이 큐잉되는 모습, 오른쪽: 큐잉된 코루틴 동작이 끝나고 다음 동작들을 수행하는 모습.

이렇게 되면 assertion 을 race condition 걱정 없이 수행할 수 있습니다.

 

위에서 `AtomicRepository` 와 `AtomicRepositoryTest` 코드를 변경한 것은 아래와 같습니다.

 

디스패처를 주입하도록 변경된 `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"
    }
}

 

디스패처를 주입하도록 변경된 `AtomicRepositoryTest`

class AtomicRepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        // 주입
        val repository = AtomicRepository(ioDispatcher = StandardTestDispatcher(testScheduler))
        repository.initialize()

        advanceUntilIdle()
        Truth.assertThat(true).isEqualTo(repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        Truth.assertThat("Hello world").isEqualTo(data)
    }
}

 

위처럼 `initialize` 메서드를 작성하면 이 메서드가 완료될 때를 외부에서 알 수 있는 방법이 없어 테스트에서 디스패처를 주입하고 보류 중인 코루틴을 진행시켰습니다.

테스트의 결과를 보면 아래와 같습니다.

놀랍게도 테스트 통과까지 걸린 시간이 50ms 입니다. 위에서 테스트 디스패처를 주입하는 방식을 쓰지 않았을 때, 이상적인(운이 좋은) 경우에서 테스트 성공했을 때의 시간은 500ms  가 넘었습니다.

즉, 이렇게 되면 테스트에서는 불필요한 지연 시간을 건너뛴 것이죠!!!

 

실제로는???  `async` , `suspend`

사실 이러한 접근 방식은 테스트에서만 사용 가능하며 실제 프로덕션(테스트가 아닌) 코드에서는 비동기 작업의 완료 여부를 추측해야 할 때는 위처럼 하지 않습니다. 

 

대신, 이 작업을 비동기적으로 처리해야 할 때 `async` 빌더를 사용해서 `Deferred` 객체를 리턴하는 방식으로 만들 수 있습니다.

 

`MyRepository` 의 `initialize` → `initializeAsync`

fun initializeAsync): Deferred<Unit>{
    return scope.async { database.populate() }
}

 

만약 `initialize` 메서드가 별도 비동기 작업을 수행할 필요가 없다면, 해당 메서드를 단순 `suspend` 함수로 만들 수도 있습니다. 이렇게 하면 동기 방식으로 동작하고, 외부에서 호출 시에 데이터 로딩이 완료된 후에 리턴됩니다.

 

`MyRepository` 의 `initialize`→ `initializeSuspend`

suspend fun initializeSuspend() {
    database.populate()
}

 

`AtomicRepository` 라면 아래처럼 되겠죠.

 

`AtomicRepository` 의 `initializeAsync`, `initializeSuspend`

fun initializeAsync(): Deferred<Unit> {
    return scope.async {
        delay(4L)
        initialized.set(true) }
}

suspend fun initializeSuspend() {
    delay(4L)
    initialized.set(true)
}

`AtomicRepositoryTest` 의 바뀐 'initialize` 테스트

@Test
fun repoInitWorksAsync() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterAtomicRepository(dispatcher)

    repository.initializeAsync().await() // Suspends until the new coroutine is done
    Truth.assertThat(true).isEqualTo(repository.initialized.get())

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

@Test
fun repoInitWorkSuspend() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterAtomicRepository(dispatcher)

    repository.initializeSuspend()
    Truth.assertThat(true).isEqualTo(repository.initialized.get())

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

이 경우에는 `advanceUntilIdle` 메서드를 호출할 필요도 없습니다.

왼쪽: Deferred<>로 리턴하는 async 빌더의 테스트, 오른쪽: suspend 함수를 테스트

 

 

 

이제 다음 글에서는 메인 디스패처를 다루는 것을 배워보도록 하겠습니다. 가능하면 Flows 나 StateFlow 도 다루어 보고 싶네요..

 

 

 

Reference

https://developer.android.com/kotlin/coroutines/test?hl=kohl=ko#groovyhttps://developer.android.com/training/dependency-injection/hilt-android?hl=ko#groovy

https://velog.io/@haero_kim/Kotlin-Coroutine-Dispatchers-1%ED%8E%B8

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

https://blog.jetbrains.com/kotlin/2021/12/introducing-kotlinx-coroutines-1-6-0/#kotlinx-coroutines-test-update

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