Kotlin Coroutine 테스트 코드

김도현·2024년 4월 9일
0

먼저 코루틴을 테스트할려면 dependency 를 설정 해야한다.

testImplementation = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"

이제 테스트시 이용하는 코루틴 스코프는 runTest를 이용하여 테스트를 진행하자.

RunTest

RunTest는 테스트 코드를 단일 스레드에서 실행할 수 있게 해주는 함수이다. 테스트 코드에서 suspend 함수를 호출하기 위해서는 코루틴 블록에서 호출해야 한다.
Coroutine1.6 이전 버전에서 코루틴을 테스트하기 위해서 runBlockingTest를 통해 테스트를 했었지만 1.6 이후 부터는 runBlockingTest가 Depercated 되었기 때문에 runTest를 사용해서 코루틴 블록을 테스트 해야 한다.

TestDispatcher

RunTest를 사용하면 기본적으로 delay로 인한 지연을 자동으로 무시하고, try-catch 되지 않은 Exception을 대신 처리해주는 특징을 가지고 있다.

@Test
fun test() = runTest {
    val currentTimeMs = Instant.now().toEpochMilli()
    delay(5000) // delay를 무시
    println("Done runTest : ${Instant.now().toEpochMilli() - currentTimeMs}ms")
}
 // Done runTest : 8ms

하지만 runTest 내부에서 새로운 코루틴을 생성하는 경우, delay를 스킵할 수 없게 된다.

@Test
fun test_withContext() = runTest {
    val currentTimeMs = Instant.now().toEpochMilli()
    withContext(Dispatchers.IO) {
        delay(5000)
    }
    println("Done runTest : ${Instant.now().toEpochMilli() - currentTimeMs}")
}
 // Done runTest : 5017ms

runTest 함수를 사용하면 코루틴 블록이 TestDispatcher를 통해 테스트 스레드에서 실행된다. 일반적으로 사용하는 Dispatcher가 어느 쓰레드에서 코루틴이 실행 시킬지 결정한다면 TestDispatcher는 테스트의 실행 순서를 제어하고 테스트에서 새 코루틴을 예약하는데 사용된다. 위의 코드처럼 withContext를 사용해 Dispatcher를 변경하게 되면 실행 스레드가 변경하기 때문에 더 이상 delay를 스킵할 수 없는 것이다.

이 내용을 바탕으로 정리하면 테스트 코드에서 코루틴이 여러 스레드에서 실행된다면 예측 가능성이 떨어지게 된다. 코루틴의 실행 시간, 실행 순서 등을 보장받지 못하기 때문이다. TestDispatcher를 사용하게 되면 하나의 스케쥴러를 공유하고 이 스케쥴러에 대해 모든 생성된 테스트 스레드가 공유하게 된다. 따라서 병목 현상을 막을 수 있으며 실행 시간, 실행 순서에 대해 보장받게 된다. 아래 그림과 같은 구조가 된다.

이러한 TestDisPatcher의 구현에는 StandardTestDispatcher와 UnconfinedTestDispatcher로 나뉜다.

StandardTestDispatcher

StandardTestDispatcher는 runTest에서 defalut 디스패처이다.
runTest에서 테스트코드를 래핑하면 기본 정지함수를 테스트할 수 있고 코루틴의 지연을 자동으로 건너뛴다.
즉 delay 함수나 기타 지연이 생기지 않는다.

private val oneDay = 86400000L

@ExperimentalCoroutinesApi
@Test
fun `기본 테스트 디스패처는 코루틴 스코프를 가장 마지막에 실행한다`() = runTest {
	println("test start")
	launch {
    	delay(oneDay)
        println("launch 1")
    }

	async {
    	println("launch 2")
    }

	println("test end")
}

lanuch에서 delay를 하루 걸었지만 실제로 실행해보면 테스트는 바로 종료된다.
print도 보면 test end가 launch같은 코루틴보다 먼저 작성되는 것을 볼 수 있다.

StandardTestDispatcher에서 새 코루틴을 시작하면 코루틴이 기본 스케줄러의 대기열에 추가되어 테스트 스레드를 사용할 있을 때마다 실행된다.

따라서 다음과 같은 코드는 실행시 문제가 된다.

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

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

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers())  // ❌ Fails
}

검증 부분이 먼저 실행되기 때문에 검증전 코루틴 스코프를 실행시켜야 한다.
여러가지 방법이 있지만 먼저 StandardTestDispatcher에선 다음과 같은 함수를 사용하여 테스트내 코루틴을 실행한다.

  • advanceUntilldle: 테스트내 모든 코루틴 스코프를 전부 실행한다.
  • advanceTimeBy: 주어진 양만큼 가상 시간을 진행하고 가상 시간의 해당 지점 전에 실행되도록 예약된 코루틴을 실행한다.
  • runCurrent: 현재 가상 시간에 예약된 코루틴을 실행한다.

이러한 특성을 이용하여 코루틴의 실행시점을 조작할 수 있다.

 @ExperimentalCoroutinesApi
 @Test
 fun `테스트의 모든 코루틴을 실행합니다`() = runTest {

	launch {
    	println("launch 1")
    }

	launch {
    	println("launch 2")
    }

	advanceUntilIdle()

	println("test end")
}

advanceTimeBy을 이용하면 mock등으로 감싼 코루틴에 delay를 걸어 조작도 가능하다.

@ExperimentalCoroutinesApi
@Test
fun `테스트를 가상시간 만큼 진행시킵니다`() = runTest {

	launch {
    	delay(oneDay*2)
        println("launch 1")
    }

	launch {
    	delay(halfDay)
        println("launch 2")
    }

	advanceTimeBy(oneDay)

	println("test end")
}

코드를 보면 하루만큼 2일간 딜레이를 건 코루틴과 반나절동안 딜레이를 건 코루틴을 생성하고 advanceTimeBy를 이용하여 가상시간 하루만큼 시간을 당겼다.
로그의 print를 보면 반나절인 lanch 2가 찍히고 테스트가 종료된 후에 스케줄러가 코루틴을 실행시킨다.

UnconfinedTestDispatcher

UnconfinedTestDispatcher는 코루틴을 생성하는 즉시 바로 실행한다.
즉, 코루틴 빌더가 반환될 때까지 기다리지 않고 즉시 실행된다.
따라서 StandardTestDispatcher와 달리 코루틴의 실행주기를 조작할순 없지만 코드가 굉장히 간결해진다.

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

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

    assertEquals(listOf("Alice", "Bob"), userRepo.getAllUsers()) // ✅ Passes
}

위의 예제코드를 하나를 UnconfinedTestDispatcher로 변환하여 실행해보겠다.

 @ExperimentalCoroutinesApi
 @Test
 fun `Unconfined은 모든 코루틴을 생성 즉시 실행합니다`() = runTest(UnconfinedTestDispatcher()) {
		println("test start")
        
        launch {
        	delay(oneDay)
            println("launch 1")
        }

        launch {
            println("launch 2")
        }

        println("test end")
    }

디스패처만 UnconfinedTestDispatcher으로 변경하고 실행한 결과이다.
테스트내에 있는 코루틴을 실행하고 종료전 시간을 무시하고 실행하는 것을 확인할 수 있다.

출처

profile
Just do it

0개의 댓글