kotlin Coroutine: 코루틴 테스트 쌩기초 탈출하기 💪

Murjune·2024년 6월 24일
8

Coroutine

목록 보기
2/8
post-thumbnail
  • runBlocking 에 대해 알고 있으면 이해하기 좋아요!
  • 코루틴의 기초만 알아도 적용 가능!

Coroutine test를 처음 접하는 당신을 위해 🌹

생각보다 정말 많은 사람들이 코루틴 테스트를 어려워하고 두려워 한다. 그러나, 코루틴 테스트 생각보다 별거 없다 두려워 하지마라😇

Junit 환경에 익숙한 개발자는 해당 포스팅만 봐도 20분이면 바로 작성 가능하다.

매번 main() 함수에 테스트해보던 당신, "진짜" 코루틴 테스트 할 준비 됐나요?

TestCoroutineScheduler, StandardTestDispatcher.. 등 어려울 수 있는 내용들은 모두 빼고, 필수적이고 바로 적용할 수 있는 내용 위주로 넣었다!
해당 내용들은 추후 포스팅하도록 하겠습니다 😁

Setting

// build.gradle.kts
dependencies {
	// coroutine
    implementation("org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.8.1")
    // coroutine Test
    testImplementation("org.jetbrains.kotlinx", "kotlinx-coroutines-test", "1.8.1")
    // Jnit5
    testImplementation("org.junit.jupiter", "junit-jupiter", "5.10.2")
    // Kotest
    testImplementation("io.kotest", "kotest-runner-junit5", "5.8.0")
}

tasks {
    test {
        useJUnitPlatform()
    }
}

runTest

많은 개발자가 suspend fun main() 이나 main() 에서 runBlocking 을 선언하여 코루틴 테스트를 해봤을 것이다.

suspend fun main() {
	val time = measureTime {
        delay(4000)
    }
    println(time) // output: 약 4s
}

fun main() = runBlocking {
	val time = measureTime {
        delay(4000)
    }
    println(time) // output: 약 4s
}

runTest 도 runBlocking과 거의 똑같은 친구다. runBlocking과 비슷하게 코루틴이 완료될 때까지 해당 스레드를 중단(blocking) 하는 함수다. (사실 runTest 내부에서 runBlocking을 호출한다)

장점)
runTest는 테스트 환경에서 돌아가기 때문에 실제 시간이 아닌 가상 시간 위에서 돌아간다는데 이점이 있다. 즉, 실행 결과는 똑같지만 해당 코루틴 내에 delay(1000) 를 걸어도 테스트 시에는 지연 시간을 무시한다.😎

위 코드를 runTest 를 활용하여 개선해보자

@Test
fun `runTest 는 가상 시간에서 돌아간다`() = runTest {
    val time = measureTime {
        delay(4000)
    }
    time shouldBeLessThan 100.milliseconds
}

위와 같이 4초 delay 시켜도 build 시 86.ms 만큼 밖에 소요되지 않는다.

따라서, 실시간이 중요하지 않는 간단한 test 의 경우, runTest 를 사용하는 것을 추천한다 😁

currentTime, advanceTimeBy, runCurrent

간단한 코루틴 테스트라면 runTest 만 사용해도 충분히 커버가 가능하다. 그러나, 가상 시간의 진척도에 따른 코루틴의 상태에 따라 테스트를 해보고 싶은 경우에는 runTest 만으로는 충분치 않은 경우가 있다.

kotlin 에서 시간 측정에 measureTime를 많이 사용한다. runTest 를 활용하면 가상 시간을 사용하기 때문에 실제 코드 실행 시간을 측정하는 measureTime 를 사용하는 것은 의미가 없다.

@Test
fun `measureTimeMillis 는 실제 시간을 측정한다`() = runTest {
    val time = measureTimeMillis {
        delay(1000_000)
    }
    println(time) // output: 1ms 가 나온다..
}

대신, advanceTimeBy(), runCurrent(), currentTime 을 활용하여 가상 시간을 조작하고 측정할 수 있다.

  • currentTime : 현재 가상 시간
  • advanceTimeBy(delayTimeMillis: Long): 특정 시간(delayTimeMillis)만큼 가상 시간을 경과 시키고, 이전에 실행해야할 작업을 진행시킨다.
  • runCurrent(): 현 가상 시간(currentTime) 시점에 pending 되고 있는 모든 coroutine을 실행시킴

이해하기 조금 어려울 수 있어, 간단한 예시를 통해 추가 설명을 하겠다.

currentTime(현재 가상시간): 0초
작업 A: 2.99초 에 실행됨
작업 B: 3초 에 실행됨

advanceTimeBy(3_000) 호출 ----> currentTime 은 3초가 되고, 작업 A가 실행된다. ----> runCurrent() 호출 --->
currentTime이 3초 이고, 작업B가 pending 상태이니 실행 시킴

코드를 통해, 해당 함수들이 어떻게 동작하는지 확인해보자!

@Test
fun `테스트 가상 환경에서 시간 경과 테스트`() = runTest {
    launch(CoroutineName("작업 A")) {
        println("작업 A start -- currentTime:${currentTime}ms")
        delay(2_999)
        println("작업 A Done -- currentTime:${currentTime}ms")
    }
    launch(CoroutineName("작업 B")) {
        println("작업 B start -- currentTime:${currentTime}ms")
        delay(3000)
        println("작업 B Done -- currentTime:${currentTime}ms")
    }
    yield()
    println("advanceTimeBy(3000) 호출")
    advanceTimeBy(3_000)
    println("runCurrent() 호출 -- currentTime:${currentTime}ms")
    runCurrent()
}

1) advanceTimeBy(3000) 를 통해 가상시간을 3초 앞당겨온 후, 작업 A 를 실행시킴
2) runCurrent() 을 통해 남은 작업 Task B 를 수행시킨다.

이번에는 job State 와 count 연산 결과를 통해 advanceTimeBy(), runCurrent() 의 실행 결과를 테스트해보자

@Test
fun `테스트 가상 환경에서 시간 경과 테스트`() = runTest {
    var count = 0
    val job = launch {
        delay(3_000)
        count++
    }
    // 가상 시간 3초 경과
    advanceTimeBy(3_000)
    currentTime shouldBe 3_000
    job.isActive.shouldBeTrue()
    count shouldBe 0
    // 남은 job 실행
    runCurrent()
    count shouldBe 1
    job.isCompleted.shouldBeTrue()
}

advanceTimeBy(3_000) 를 통해 가상 시간을 3초 앞당겨와도 job 의 상태는 실행중 이고 count++ 은 아직 실행되지 않았다. runCurrent() 를 실행해야 비로소 job이 종료되고 count++ 가 실행되는 것을 확인할 수 있다.😙

그림으로 확인하면 다음과 같다.

advanceUntilIdle() ⭐️⭐️

advanceTimeBy()runCurrent() 함수도 매우 유용한 함수지만, 개발자가 임의로 delay 건 시간만큼 시간을 경과시키는 것은 귀찮고 잘못된 테스트 케이스를 작성할 수도 있다.

이럴 때 advanceUntilIdle()를 사용하자! 😎

@Test
fun `advanceUntilIdle`() = runTest {
    var count = 0
    val job1 = launch(CoroutineName("child1")) {
        delay(3000)
        count++
    }
    val job2 = launch(CoroutineName("child2")) {
        delay(5000)
        count++
    }
    advanceUntilIdle()
    currentTime shouldBe 5000
    count shouldBe 2
    job1.isCompleted.shouldBeTrue()
}
  • advanceUntilIdle() : runTest 내부 코루틴 동작들이 모두 마칠 때까지 기다림

advanceTimeBy(), runCurrent() 보다는advanceUntilIdle() 를 사용하는 경우가 대부분이다.😇


이번 포스팅에서는 코루틴 테스트를 사용하는데 초점을 맞춰 작성하였습니다.
웬만한 테스트에서 runTestadvanceUntilIdle() 만으로도 충분히 커버가 되지 않을까 싶네요 😄

다음에는 TestScope, StandardTestDispatcher, TestCoroutineScheduler, UnconfinedTestDispatcher 에 대해 정리한 후, runTest 에 대해 더 자세하게 설명하도록 할게요 🌹

(추가) runCurrent() 함수 및 이해를 위한 설명 및 그림도 추가해보았습니다 😁

profile
열심히 하겠슴니다:D

4개의 댓글

comment-user-thumbnail
2024년 7월 2일

좋은 글이네요 - 의문의 누씨

답글 달기
comment-user-thumbnail
2024년 7월 8일

이해가 쏙쏙!

답글 달기
comment-user-thumbnail
2024년 7월 8일

질문이 있는데 혹시 메일 드려도 될까요?

1개의 답글

관련 채용 정보