본문 바로가기

안드로이드/Kotlin

[Kotlin] Introduction to Coroutines and Channels-7

개요

해당 게시글은 Welcome to Kotlin hands-on (kotlinlang.org)을 번역한 게시글 입니다.

 

Testing Coroutines

코루틴으로 작성된 코드를 어떻게 테스트하는지 확인해보자. Concurrent 코루틴을 실행하는 솔루션이 suspend 함수를 활용한 솔루션 보다 빠른지 확인하고, 채널이 있는 솔루션이 단순한 "progress" 솔루션 보다 빠른지 확인하는게 좋다. 이런 솔루션의 총 실행 시간을 비교하는 방법에 대해 살펴보자.

 

Gihub service를 mocking하고 아래 주어진 시간에 리턴하도록 구현하자.

repos request - returns an answer within 1000 ms delay
repo-1 - 1000 ms delay
repo-2 - 1200 ms delay
repo-3 - 800 ms delay

이제 suspend를 활용한 직렬적 솔루션이 4000ms 정도 걸리는지 테스트할 수 있다.

4000 = 1000 + (1000 + 1200 + 800)

 

그리고 concurrent 솔루션은 2200ms가 소요된다.

2200 = 1000 + max(1000,1200,800)

 

또한 솔루션이 진행 상황을 보이기 위해 중간 결과를 타임스탬프를 찍어 확인해보아야 한다.

 

Mock service를 호출할 때 사용하는 상응하는 테스트 데이터는 test/contributors/testData.kt에 존재한다.

 

하지만, 두 개의 문제가 존재한다:

  • 이 테스트들은 수행하는데 너무 오래 걸린다. 각 테스트가 2~4초가 소요되어 각 시도마다 이를 기다려야 된다. 이런 접근법은 효율적이지 않다.
  • 솔루션이 실행되는 어떤 정확한 시간에 의존할 수 없다. 코드가 준비를 마치고 동작하는데까지 수행되는데 추가적인 시간이 소요되기 때문이다. 상수를 추가할 수도 있지만 기계마다 다른 것도 문제이다. 차이점을 확인할 수 있도록 Mock service의 딜레이는 이 상수보다 길어야 된다. 예를 들어, 상수가 0.5초이면 지연 시간을 0.1초로 하는 것은 충분하지 않다.

더 나은 방법은 같은 코드를 여러번 동작시키는 동안 시간을 기록할 수 있는 특정 프레임워크를 사용하는 것이다. 하지만 이는 새로 배우고 설치하는게 복잡하다.

 

하지만 이 경우에 단순히 어떤 해결책 보다 다른 해결책이 빠르다 정도의 매우 간단한 테스트를 해보고 싶은 것이기 때문에 실제에 적용되는 성능 테스트에는 별로 관심이 ㅇ벗다.

 

 이 문제를 해결하기 위해 '가상 시간'이라는 개념을 사용할 수 있다. 이를 위해 특별한 test dispatcher가 필요하다. 시작 부터 경과된 가상 시간을 추적하고 모든 것을 실시간으로 즉시 실행한다. 이 dispatcher에서 코루틴을 실행하면 딜레이가 반환된다.

 

이런 메커니즘을 활용하는 테스트는 빠르지만 가상 시간의 서로 다른 시점에서 무슨 일이 일어나는지 확인할 수 있다. 총 수행 시간도 매우 짧아진다.

어떻게 가상 시간을 활용하는 걸까? 먼저, runBlocking을 runBlockingTest로 교체한다. runBlocking은 람다 확장자를 인수로서 TestCoroutineScope을 사용한다. 특정 scope 내부의 suspend 함수에서 delay를 호출하면 delay는 실제 시간 대신 가상 시간을 늘어나게 한다.

@Test
fun testDelayInSuspend() = runBlockingTest {
    val realStartTime = System.currentTimeMillis()
    val virtualStartTime = currentTime

    foo()

    println("${System.currentTimeMillis() - realStartTime} ms")  // ~ 6 ms
    println("${currentTime - virtualStartTime} ms")              // 1000 ms
}

suspend fun foo() {
    delay(1000)     // auto-advances without delay
    println("foo")  // executes eagerly when foo() is called
}

현재 가상 시간을 TestCoroutineScope인 currentTime 프로퍼티로 확인할 수 있다. 즉, 위에서는 실제 시간으로는 6ms가 지났지만 가상 시간으로는 설정한 delay 값인 1000ms가 지났다.

 

자식 코루틴에서 delay를 통해 이런 "가상"의 충분한 효과를 누리기 위해 모든 자식 코루틴을 TestCoroutineDispatcher로 수행한다. 그렇지 않으면 동작하지 않을 것이다. 이 dispatcher는 다른 dispatcher를 제공하지 않는다면 외부 TestCoroutineScope를 자동으로 상속한다.

@Test
fun testDelayInLaunch() = runBlockingTest {
    val realStartTime = System.currentTimeMillis()
    val virtualStartTime = currentTime

    bar()

    println("${System.currentTimeMillis() - realStartTime} ms")  // ~ 11 ms
    println("${currentTime - virtualStartTime} ms")              // 1000 ms
}

suspend fun bar() = coroutineScope {
    launch {
        delay(1000)     // auto-advances without delay
        println("bar")  // executes eagerly when bar() is called
    }
}

위 예제는 launch를 Dispatcher.Default와 함께 호출하려고 시도하고 테스트가 실패하는 것을 확인할 수 있을 것이다. Job이 아직 끝나지 않았다는 예외를 확인할 수 있을 것이다.

 

이러한 방식으로 loadContributorsConcurrent 함수는 Dispatcher.Default를 사용하는 것을 수정하지 않고 상속된 context에서 자식 코루틴을 시작하는 경우에만  테스트를 수행할 수 있다. 함수를 정의하는 것이 아닌 호출 할 때 Dispatcher와 같은 context 요소를 명시할 수 있다. 이게 테스트하기 더 유연하고 쉽다.

 

가상 시간이 존재하는 Testing API는 실험 버전이며 향후 변경될 수 있다. 그래서 기본적으로 사용할 경우 컴파일러 경고가 효시된다. 이 경고를 무시하기 위해 @OptIn(ExperimentalCoroutinesApi::class) 주석을 테스트 클래스나 함수에 추가해야 한다. 이 주석을 추가함으로써 이 API가 변경될 수도 있고 필요에 따라 업데이트를 준비해야할 수도 있다는 것을 강조할 수 있게 된다.

 

Solution

fun testConcurrent() = runBlockingTest {
    val startTime = currentTime
    val result = loadContributorsConcurrent(MockGithubService, testRequestData)
    Assert.assertEquals("Wrong result for 'loadContributorsConcurrent'", expectedConcurrentResults.users, result)
    val totalTime = currentTime - startTime

    Assert.assertEquals(
        "The calls run concurrently, so the total virtual time should be 2200 ms: " +
                "1000 for repos request plus max(1000, 1200, 800) = 1200 for concurrent contributors requests)",
        expectedConcurrentResults.timeFromStart, totalTime
    )
}
fun testChannels() = runBlockingTest {
    val startTime = currentTime
    var index = 0
    loadContributorsChannels(MockGithubService, testRequestData) {
        users, _ ->
        val expected = concurrentProgressResults[index++]
        val time = currentTime - startTime
        Assert.assertEquals("Expected intermediate results after ${expected.timeFromStart} ms:",
            expected.timeFromStart, time)
        Assert.assertEquals("Wrong intermediate results after $time:", expected.users, users)
    }
}