Spring

RunTest에 대해서...

develua 2023. 3. 26. 18:35

코루틴을 프로젝트에서 사용하다보면, 일시 중단 함수를 테스트하거나 테스트 코드 내부적으로 호출해야할 일이 빈번하게 발생한다.

하지만, 테스트를 진행하는 JUnit 환경에선 테스트 함수가 일시 중단 함수가 아니기 때문에 의무적으로 최상위 코루틴 스코프를 생성해 해당 스코프 내에서 일시 중단 함수를 호출해야 했다.

@Test  
fun test() {  
    runBlocking {   
        // 일시 중단 함수 호출  
        // 일시 중단 함수 호출  
    }  
}

 

문제 - 테스트 코드 내에서 delay() 함수를 사용

이런 처리를 일일이 해야하는 것에 번거로움을 느낄때쯤, 기대한 처리가 완료될 때까지 대기하는 delay(1000) 함수를 테스트 코드 내에서 사용해야할 일이 발생했다.

사내에서 제공하고 있는 구매 서비스는 기본적으로 구매 요청을 처리하는 도중 문제가 발생하면, 일단 클라이언트로 구매 실패 결과를 반환하고, 별개의 스레드에서 복구 로직을 탈 수 있도록 구현이 되어있다. 문제는 별개의 스레드에서 동작하는 해당 복구 로직이 정상 동작하는지를 테스트 하고자 할 때 발생했다.

 

기본적인 테스트 코드 틀은 하기 코드와 같다. (실제 코드와 다를 수 있음)

@Test  
fun testPayPurchase() {  
    // given  
    // 구매 생성  
    val purchase = testScenario.createPurchaseAndItems()  
    val request = FakePayPurchaseRequest.create(purchase.id)  

    // when  
    assertThrows<Exception> {  
        runBlocking {  
            service.payPurchase(request)  
        }  
    }  
    // then  
    runBlocking {  
        delay(1000)  
        // 복구 로직 정상 실행 여부 검증
    }  
}

만약, 구매 실패한 이후 복구 로직이 순차적으로 모두 실행이 된 후 반환이된다면, payPurchase(request) 호출 이후 검증 단계에서는 복구 로직이 실행됐을거라 기대할 수 있으나, 상기 내용과 같이 구현된 케이스라면(일단 응답 반환 후, 별개의 스레드에서 복구 로직 처리), 응답값이 반환된 이후에 복구 로직이 동작할 것임으로 검증 시점에는 복구 로직 실행 여부를 확신할 수 없었다.

그렇기 때문에 이런 상황에선 delay() 함수를 이용해 잠시 검증 시점을 복구 로직이 완료됐을 거라 예상되는 시점까지 충분히 지연시켜야 했다.

하지만, 테스트 코드 내에서 위와 같이 delay() 함수를 이용하는 상황이 연출되면서 자연스레 총 테스트 코드 실행 시간에는 지연 시간이 포함됐고, 이는 전체 테스트 실행 속도를 현저히 낮추게 되었다.

 

delay() 처리를 하지만, 하지 않는 그런 방법을 없을까?

상황이 그렇다보니, delay() 처리를 하지만, 시간 지연만큼 대기하지 않을 수 있는 방법은 없을까? 를 고민하게 되었고, 고민하다 runTest 테스트 스코프를 이용하는 방법을 알게되었다.

 

일반 테스트 코드는 일시 중단 함수가 아니기때문에 일시 중단 함수를 호출하기 위해서는 코루틴이 있어야 한다. 그래서 코루틴 빌더를 호출하고, 그 안에서 일시 중단 함수를 호출해야 한다.

 

하지만, runTest 스코프은 그 자체로 최상위 코루틴 빌더의 역할을 하다보니, 따로 테스트 내에 1) 기존과 같이 runBlocking 스코프로 감싸는 행위를 진행하지 않아도 되며, 2) delay() 처리를 하더라도 지연을 자동으로 건너띄므로 처음 내가 원했던 요구 사항인 delay() 처리를 하지만, 하지 않는(시간상으론 그렇지 않는) 그런 상황에 도달할 수 있다. 그래서 테스트 코드 실행 시간을 단축시킬 수 있다!

 

runTest 함수의 구현은 하기 코드와 같다.

// 코루틴의 실험적인 API 를 지칭하는 것으로 아직 완전히 안정화되지 않은 코루틴 라이브러리의 API를 의미
@ExperimentalCoroutinesApi  
public fun runTest(  
    context: CoroutineContext = EmptyCoroutineContext,  
    dispatchTimeoutMs: Long = DEFAULT_DISPATCH_TIMEOUT_MS,  
    // 필요한 테스트 코드 구현
    testBody: suspend TestScope.() -> Unit  
): TestResult {  
    if (context[RunningInRunTest] != null)  
        throw IllegalStateException("Calls to `runTest` can't be nested. Please read the docs on `TestResult` for details.")  
    return TestScope(context + RunningInRunTest).runTest(dispatchTimeoutMs, testBody)  
}

 

runTest 빌더를 이용한 테스트 코드를 작성

간단하게 테스트를 진행해보자.

아래의 코드로 테스트를 진행해 봤을 때, 실제로는 최소 2초의 시간이 소요되는것이 맞으나, 실제로는 0.2초도 채 걸리지 않는다.

@Test  
fun exampleTest() = runTest {  
    val deferred = async {  
        delay(1_000)  
        async {  
            delay(1_000)  
        }.await()  
    }  
    deferred.await() // result available immediately  
}

runTest 빌더를 이용한 테스트 코드 결과

 

delay-skipping은 어떻게 가능했던 것일까?

결론부터 이야기 하자면, testScope 내의 testScheduler가 관리하는 virtualTime 에 의해 작업간 상대적인 순서에 대한 제어가 가능하기 때문에 위와 같이 delay-skipping 처리가 가능한 것이다.
또한, runTest 빌더로 생성된 코루틴이 여러개의 스레드를 오가며 병렬적으로 실행되는 것이 아닌 실제로는 테스트 시작과 동시에 생성된 스레드 하나에서만 코루틴이 실행되기 때문에 작업간 상대적인 순서에 대한 제어가 좀 더 수월해 진다.

뿐만 아니라, testScheduler가 관리하는 virtualTime 에 의해 delay 처리를 스킵했지만, 테스트 로직 처리 중의 currentTime 은 시간 지연을 그대로 반영하는 효과를 가져온다.

@Test
fun testWithMultipleDelays() = runTest {
    launch {
        delay(1_000)
        println("1. $currentTime") // 1000
        delay(200)
        println("2. $currentTime") // 1200
        delay(2_000)
        println("4. $currentTime") // 3200
    }
    val deferred = async {
        delay(3_000)
        println("3. $currentTime") // 3000
        delay(500)
        println("5. $currentTime") // 3500
    }
    deferred.await()
}

이는 또한 복수의 TestDispathcers 를 생성할 때, testScheduler를 공유할 수 있도록 해 생성된 코루틴들은 그들의 virtual time에 대한 정보를 공유할 수 있도록 구현되어 있다.
즉, 생성되는 복수의 Dispatcher 들이 모두 하나의 Schedular를 공유한다면, 상이한 CoroutineContext를 기반으로 생성된 코루틴이더라도 실행 순서를 제어하고 지연 시간을 조정하는 데 필요한 동기화를 유지할 수 있게 된다.

@Test
fun testWithMultipleDispatchers() = runTest {
        val scheduler = testScheduler // the scheduler used for this test
        val dispatcher1 = StandardTestDispatcher(scheduler, name = "IO dispatcher")
        val dispatcher2 = StandardTestDispatcher(scheduler, name = "Background dispatcher")
        launch(dispatcher1) {
        delay(1_000)  
        println("[dispatcher1] 1. currentTime: $currentTime") // 1000  
        delay(200)  
        println("[dispatcher1] 2. currentTime: $currentTime") // 1200  
        delay(2_000)  
        println("[dispatcher1] 4. currentTime: $currentTime") // 3200
        }
        val deferred = async(dispatcher2) {
        delay(3_000)  
        println("[dispatcher2] 3. currentTime: $currentTime") // 3000  
        delay(500)  
        println("[dispatcher2] 5. currentTime: $currentTime") // 3500
        }
        deferred.await()
    }

 

(번외)TestDispatcher의 유형

잠깐, TestDispatcher에 대해 알고 지나가 보자.

 

상기 코드에서 얼핏 StandardTestDispatcher 라는게 보였는데, 짐작하기로는 테스트 용 Dispatcher로 보여진다.
테스트 용으로 사용할 수 있는 TestDispatcher의 어떤 종류가 있는것일까?

 

TestDispatcher 에는 2가지의 구현이 있다.

  1. StandardTestDispatcher
  2. UnConfinedTestDispatcher

✔️ StandardTestDispatcher

StandardTestDispatcher 는 가장 기본적인 구현이다. TestScope 이 생성될 때, 따로 Dispatcher 를 지정하지 않으면 기본구현인 StandardTestDispatcher 로 지정된다.
StandardTestDispatcher 는 Dispatcher 자체가 코루틴의 실행을 관장하지 않고, Schedular에게 코루틴 실행을 위임한다.

코루틴 빌더에 의해 생성된 코루틴은 대기열에 쌓이게 되고, Schedular는 대기열에 있는 코루틴을 테스트 스레드가 가용한 상태가 될때마다 가용한 스레드에서 코루틴을 실행시킨다.
그렇다 보니, 대기열에 쌓인 코루틴들은 테스트 스레드가 가용한 시점까지 대기하게 된다.

✔️ UnConfinedTestDispatcher

반면, UnConfinedTestDispatcher 에 경우, 빌더가 코루틴을 반환하면 해당 코루틴이 Schedular에 등록과 관계없이 진행 중인 스레드에서 즉시 실행한다.

그렇기 때문에 하기 테스트 코드는 결과와 같이 통과한다. 코루틴을 Schedular에 등록하고, 가용 스레드 내에서 실행되도록 제어하는 과정없이 호출자의 스레드에서 즉시 실행되기 때문이다.

@Test  
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {  
    val userRepo = mutableListOf<String>()  

    launch {  
        userRepo.add("Alice")  
        println("[1] $currentTime")  
    }  
    launch {  
        userRepo.add("Bob")  
        println("[2] $currentTime")  
    }  

    assertEquals(listOf("Alice", "Bob"), userRepo) // ✅ Passes    
    println("[3] $currentTime")  
}

 

⁉️ StandardTestDispatcher 와는 어떤 차이가 있을까? 

 

반면에, 동일 코드를 StandardTestDispatcher 에서 실행되도록 하면, 실패하게 된다. Schedular에 의한 코루틴의 제어 작업이 추가적으로 필요하기 때문에 검증부(assert)가 우선 실행되기 때문이다.

@Test  
fun standardTest() = runTest(StandardTestDispatcher()) {  
    val userRepo = mutableListOf<String>()  

    launch {  
        userRepo.add("Alice")  
        println("[1] $currentTime")  
    }  
    launch {  
        userRepo.add("Bob")  
        println("[2] $currentTime")  
    }  

    assertEquals(listOf("Alice", "Bob"), userRepo)
    println("[3] $currentTime")  
}

 

⁉️실행되는 코루틴 내에 일시 중단 상태가 발생한다면? 

 

단, 코루틴 실행 중에 delay() 함수를 직면하게 된다면, 결과가 달라진다. 일시 중단 상태에 따라서 다른 작업이 해당 스레드를 점유할 수 있게 되기 때문이다.
예를 들면, 아래와 같은 테스트에서는 검증이 실패하게 된다. 1의 delay에 의해 2, 3 이 우선적으로 실행됐기 때문이다.

@Test  
fun unconfinedTest() = runTest(UnconfinedTestDispatcher()) {  
    val userRepo = mutableListOf<String>()  

    // 1
    launch {  
        delay(1_000) 
        userRepo.add("Alice")  
    }  
    // 2
    launch { userRepo.add("Bob") }  
    // 3
    assertEquals(listOf("Alice", "Bob"), userRepo) 

스케줄러에게 실행을 위임하지 않았으나, UnconfinedTestDispatcher 도 내부 구현에서는 TestCoroutineScheduler 를 가지고 있기 때문에 delay-skipping까지 모두 StandardTestDispatcher와 동일하게 지원가능하다.

그래서 1) 코루틴이 실행돼야할 스레드를 특정하지 않아도 무관한 경우 코루틴을 2) 스케줄러의 관여 없이 빠르게 즉시 동작시키고 싶을 때, 해당 Dispatcher 를 사용하면 된다.

 

하지만, 실제 환경과 유사하게 동작하지 않기 때문에 코루틴관련 간단한 테스트를 진행하기에 적합한 디스패처라고 안드로이드 개발자 가이드에서는 권고하고 있다.

 

결론적으로, runTest 스코프를 사용해 해당 스코프 내부에서 코루틴을 생성한다면, 스케줄러에 의해 코루틴들이 스케줄링 되고, delay() 처리를 하더라도 실제 그 시간만큼 대기하지 않아도 테스트를 진행할 수 있다.
또한, 정밀한 테스트를 위해 목적으로 어떤 종류의 dispatcher를 사용하던 간에 하나의 스케줄러를 모든 dispatcher가 공유할 수 있도록만 한다면 동일하게 테스트를 진행할 수 있다.

 

다시 처음 직면했던 문제로...

나의 경우에는 이용자의 구매 처리 실패 시, 실패의 이유와 관계없이 복구 로직이 반드시 실행되어야 했음으로 복구 로직 처리는 별도의 withContext 함수를 이용해서 별도의 테스트 용이 아닌 NonCancellable한 CoroutineContext에서 동작하게끔 구현이 된 상태였다.

이런 경우에도 동일한 테스트 스케줄러를 이용한다면, delay-skipping 효과를 볼 수 있다.

 

동일한 테스트 스케줄러가 사용될 수 있는지, 테스트 코드로 재현해 보자.

 

하기 코드와 같이 현재 테스트의 TestCoroutineScheduler를 갖는 testDispatcher를 생성한 뒤, 해당 Dispatcher 를 withContext 함수의 인자로 넘겨준다.
이렇게 되면, NonCancellable 한 새로운 CoroutineContext를 기반으로 코루틴이 생성되더라도, 테스트 스케줄러를 그대로 상속받아 테스트 스케줄러의 관리하에 동작하게 된다.

 

결과와 같이 delay-skipping 이 정상 동작하였다.

@Test
fun exampleTest10() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)

    val validateVar = withContext(dispatcher) {
        delay(5000)
        // 복구 로직 대체 로직
        testRecover()
    }
    println("currentTime: $currentTime")
    println("validateVar: $validateVar")
}

suspend fun testRecover(): Boolean {
    withContext(NonCancellable){
        delay(2000)
    }
    return true
}

 

또한, 테스트 코드 내에서 withContext 함수를 실행할 때, 기본적으로 testDispatcher로 동작이 된다.
예를 들면, 아래 케이스에선 따로 testDispatcher를 따로 정의해 주입해 주지는 않았으나 delay-skipping이 동작했다.

@Test
fun exampleTest11() = runTest {
    val currentTimeMillis = System.currentTimeMillis()
    val validateVar = testRecover()
    println("currentTime: $currentTime")
    println("System.currentTimeMillis: ${System.currentTimeMillis() - currentTimeMillis}")
    println("validateVar: $validateVar")
}
suspend fun testSuspend() {
    withContext(NonCancellable){
        delay(2000)
    }
}

결과적으로, 기존에는 delay() 함수를 통해 복구 로직이 실행완료했을 것이라고 기대하는 시점까지 지연시키는 코드를 추가하더라도, delay-skipping 을 통해 지연을 시키지만, 시키지 않는 상태를 구현할 수 있다.

 

변경된 구매 로직 테스트 코드는 하기와 같다.

@Test
fun testPayPurchase() = runTest {
    // given
    // 구매 생성
    val purchase = testScenario.createPurchaseAndItems()
    val request = FakePayPurchaseRequest.create(purchase.id)

    // when
    assertThrows<Exception> {
        service.payPurchase(request)
    }
    // then
    // 복구 로직 정상 실행 여부 검증
    delay(1000)
    assertSomething()
}

 

(번외)TestCoroutineDispatcher 를 사용하지 않는 경우는?

일반적으로 TestDispatcher의 주입을 통해서 delay-skipping 이 가능하나, TestCoroutineScheduler를 사용하지 않는 디스패처를 기반으로 코루틴이 생성된다면 delay-skipping 적용이 불가능하다.

 

예시 코드는 하기와 같다.

 

async 빌더 내의 delay(1000) 은 skip이 되지만, 아래 주석과 같이 TestCoroutineScheduler를 사용하지 않는 디스패처 내에서 실행되는 코드 지연은 건너뛸 수 없다.

@Test
fun exampleTest() = runTest {
    val elapsed = TimeSource.Monotonic.measureTime {
        val deferred = async {
            delay(1_000) // will be skipped
            withContext(Dispatchers.Default) {
                delay(5_000) // Dispatchers.Default doesn't know about TestCoroutineScheduler
            }
        }
        deferred.await()
    }
    println(elapsed) // about five seconds
}