Coroutine ‐ Coroutine Testing - thought-corner/Backend-PlayGround GitHub Wiki

코틀린 단위 테스트 기초 - 테스트 더블

  • 테스트 더블은 객체에 대한 대체물로 객체의 행동을 모방하는 객체이다.
  • 다른 객체와 의존성을 가진 객체를 테스트하기 위해 테스트 더블이 필요하다.
  • 테스트 더블의 종류로는 대표적으로 스텁(Stub), 페이크(Fake), 목(Mock) 이 3가지가 있다.
  • Stub
    • 스텁이란, 미리 정의된 데이터를 반환하는 모방 객체이다. 반환 값이 없는 함수는 구현하지 않고 반환 값이 있는 동작만 미리 정의된 데이터를 반환하도록 구현한다.
    • 이 때, 스텁을 만들 때 미리 정의된 데이터를 내부 프로퍼티로 고정하면 유연하지 못해진다.
    • 유연하게 만들기 위해서는 미리 정의된 데이터를 주입 받는 형태로 만들어야 한다.
  • Fake
    • 페이크란, 실제 객체와 비슷하게 동작하도록 구현된 모방 객체이다.

코루틴 단위 테스트

  • 간단한 일시 중단 함수를 테스트 할 때는 runBlocking() 함수를 사용해 코루틴을 만들면 된다.
  • runBlocking() 함수를 사용해 실행에 오랜 시간이 걸리는 일시 중단 함수를 테스트하면 문제가 생긴다.
class RepeatAddUseCaseTest {
    @Test
    fun `100번 더하면 100이 반환된다`(): Unit = runBlocking {
        // Given
        val repeatAddUseCase = RepeatAddUseCase()

        // When
        val result: Int = repeatAddUseCase.add(100)

        // Then
        assertEquals(100, result)
    }
}

코루틴 테스트 라이브러리

  • 코루틴 테스트 라이브러리는 TestCoroutineScheduler 객체를 통해 가상 시간에서 테스트를 진행할 수 있도록 하는 기능을 제공한다.
  • TestCoroutineScheduler 객체를 사용하면 시간을 자유자재로 다룰 수 있다.
  • TestCoroutineScheduler.advanceTimeBy() : 가상 시간을 흐르게 만들 수 있다.
  • TestCoroutineScheduler.currentTime : 현재 시간(가상 시간)을 알 수 있다.

코루틴 테스트 라이브러리 활용 1 - StandardTestDispatcher 사용해 가상 시간 위에서 테스트하기

  • TestCoroutineScheduler 객체는 TestDispatcher 객체를 만드는 StandardTestDispatcher 함수와 함께 사용할 수 있다.
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTest {

    @Test
    fun `가상 시간 위에서 테스트 진행`() {
        // Given: 테스트 환경 설정
        val testCoroutineScheduler = TestCoroutineScheduler()
        val testDispatcher = StandardTestDispatcher(scheduler = testCoroutineScheduler)
        val testCoroutineScope = CoroutineScope(context = testDispatcher)

        var result = 0

        // When: 코루틴 실행 (즉시 시작되지 않고 스케줄러에 등록됨)
        testCoroutineScope.launch {
            delay(10000L) // 10초 대기
            result = 1
            delay(10000L) // 추가 10초 대기
            result = 2
        }

        // Then: 가상 시간을 흐르게 하며 결과 검증
        
        // 1. 5초 흐르게 함 (총 5초 지남) -> delay(10000) 중이므로 result는 아직 0
        testCoroutineScheduler.advanceTimeBy(5000L)
        assertEquals(0, result)

        // 2. 추가 6초 흐르게 함 (총 11초 지남) -> 첫 번째 delay(10000) 종료, result는 1이 됨
        testCoroutineScheduler.advanceTimeBy(6000L)
        assertEquals(1, result)

        // 3. 추가 10초 흐르게 함 (총 21초 지남) -> 두 번째 delay(10000) 종료, result는 2가 됨
        testCoroutineScheduler.advanceTimeBy(10000L)
        assertEquals(2, result)
    }
}

코루틴 테스트 라이브러리 활용 2 - advanceUntilIdle 사용해 모든 코루틴 실행

  • TestCoroutineScheduleradvanceUntilIdle() 함수가 호출되면 가상 시간 스케줄러를 사용하는 모든 코루틴이 완료될 때까지 시간이 흐른다.
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineAdvanceTest {

    @Test
    fun `advanceUntilIdle의 동작 살펴보기`() {
        // Given: 테스트 환경 설정
        val testCoroutineScheduler = TestCoroutineScheduler()
        val testDispatcher = StandardTestDispatcher(scheduler = testCoroutineScheduler)
        val testCoroutineScope = CoroutineScope(context = testDispatcher)

        var result = 0

        // When: 코루틴 실행 (총 20초의 대기 시간이 포함됨)
        testCoroutineScope.launch {
            delay(10_000L) // 10초 대기
            result = 1
            delay(10_000L) // 추가 10초 대기
            result = 2
        }

        // advanceUntilIdle() 호출: 
        // 스케줄러에 남아있는 모든 작업(위의 두 번의 delay 포함)이 끝날 때까지 
        // 시간을 자동으로 점프합니다.
        testCoroutineScheduler.advanceUntilIdle()

        // Then: 모든 작업이 완료되었으므로 result는 최종값인 2가 됨
        assertEquals(2, result)
    }
}

코루틴 테스트 라이브러리 활용 3 - TestCoroutineScheduler을 포함하는 StandardTestDispatcher

  • StandardTestDispatcher 함수는 TestCoroutineScheduler을 생성하는 부분을 포함한다.
@Suppress("FunctionName")
public fun StandardTestDispatcher(
    scheduler: TestCoroutineScheduler? = null,
    name: String? = null
): TestDispatcher = StandardTestDispatcherImpl(
    scheduler ?: TestMainDispatcher.currentTestScheduler ?: TestCoroutineScheduler(), name)

TestScope

  • TestScope를 사용하면 내부에 TestDispatcher을 가진 TestScope 객체가 반환된다.
  • TestScope은 확장 함수를 통해 advancedUntilIdle, advanceTimeBy를 직접 호출할 수 있도록 한다.
/**
 * A coroutine scope that for launching test coroutines.
 *
 * The scope provides the following functionality:
 * - The [coroutineContext] includes a [coroutine dispatcher][TestDispatcher] that supports delay-skipping, using
 *   a [TestCoroutineScheduler] for orchestrating the virtual time.
 *   This scheduler is also available via the [testScheduler] property, and some helper extension
 *   methods are defined to more conveniently interact with it: see [TestScope.currentTime], [TestScope.runCurrent],
 *   [TestScope.advanceTimeBy], and [TestScope.advanceUntilIdle].
 * - When inside [runTest], uncaught exceptions from the child coroutines of this scope will be reported at the end of
 *   the test.
 *   It is invalid for child coroutines to throw uncaught exceptions when outside the call to [TestScope.runTest]:
 *   the only guarantee in this case is the best effort to deliver the exception.
 *
 * The usual way to access a [TestScope] is to call [runTest], but it can also be constructed manually, in order to
 * use it to initialize the components that participate in the test.
 *
 * #### Differences from the deprecated [TestCoroutineScope]
 *
 * - This doesn't provide an equivalent of [TestCoroutineScope.cleanupTestCoroutines], and so can't be used as a
 *   standalone mechanism for writing tests: it does require that [runTest] is eventually called.
 *   The reason for this is that a proper cleanup procedure that supports using non-test dispatchers and arbitrary
 *   coroutine suspensions would be equivalent to [runTest], but would also be more error-prone, due to the potential
 *   for forgetting to perform the cleanup.
 * - [TestCoroutineScope.advanceTimeBy] also calls [TestCoroutineScheduler.runCurrent] after advancing the virtual time.
 * - No support for dispatcher pausing, like [DelayController] allows. [TestCoroutineDispatcher], which supported
 *   pausing, is deprecated; now, instead of pausing a dispatcher, one can use [withContext] to run a dispatcher that's
 *   paused by default, like [StandardTestDispatcher].
 * - No access to the list of unhandled exceptions.
 */
public sealed interface TestScope : CoroutineScope {
    // ...
}

runTest 사용해 테스트 만들기

  • runTest() 함수는 TestScope 객체를 사용해 코루틴을 실행시키고 그 코루틴 내부에서 일시 중단 함수가 실행되더라도 가상 시간을 자동으로 흐르게 해 곧바로 실행 완료될 수 있도록 하는 코루틴 빌더 함수이다.
  • 즉, advanceUntilIdle을 호출하지 않더라도 가상 시간이 흐른다.
  • runTest() 함수는 테스트 전체를 감싸는 방식으로 자주 사용된다.
  • runTest() 함수는 runTest() 함수로 생성된 코루틴의 시간만 흐르게 만든다. runTest() 함수를 호출해 생성된 TestScope을 사용해 새로운 코루틴이 실행된다면 이 코루틴은 자동으로 시간이 흐르지 않게 된다. 이런 경우 advanceUntilIdle() 함수를 명시적으로 호출해줘야 한다.
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineRunTest {

    @Test
    fun `runTest 사용하기`() = runTest {
        // Given
        var result = 0

        // When: runTest 내부에서는 delay를 자동으로 건너뜁니다.
        // 별도의 스케줄러 설정이나 advanceTimeBy 호출 없이도 
        // 20초가 지난 후의 상태를 즉시 확인할 수 있습니다.
        delay(10_000L) // 10초 대기
        result = 1
        delay(10_000L) // 10초 대기
        result = 2

        // Then
        assertEquals(2, result)
    }
}
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineAdvanceTest {

    @Test
    fun `runTest 내부에서 advanceUntilIdle 사용하기`() = runTest {
        var result = 0

        // 별도의 코루틴을 시작 (runTest는 이 안의 코드가 끝날 때까지 기다리지 않음)
        launch {
            delay(1000L) // 1초 대기
            result = 1
        }

        // 1. 아직 자식 코루틴이 수행되지 않음
        println("가상 시간: ${this.currentTime}ms, result = $result")

        // 2. 자식 코루틴이 완료될 때까지 가상 시간을 이동
        advanceUntilIdle()

        // 3. 자식 코루틴이 모두 완료됨
        println("가상 시간: ${this.currentTime}ms, result = $result")
    }
}

코루틴 단위테스트 심화

  • 일시 중단 함수 내부에서 새로운 코루틴을 생성하는 경우는 테스트가 쉽지만, 일시 중단 함수가 아닌 일반 함수 내부에서 새로운 코루틴을 실행하는 경우도 있다.
  • 이런 경우 StringStateHolder 내부의 CoroutineScope 객체는 별도의 루트 Job 객체를 가져 runTest로 실행되는 코루틴과 구조화가 되지 않고, Dispatchers.IO를 사용해 코루틴을 실행하기 때문에 실제 시간 위에서 실행된다.
  • StringStateHolderCoroutineDispatcher을 주입받도록 구현을 변경해 해결할 수 있다.
class StringStateHolder(
    // 테스트 가능하도록 디스패처를 주입받음 (기본값은 Dispatchers.IO)
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    private val coroutineScope = CoroutineScope(dispatcher)

    var stringState = ""
        private set

    fun updateStringWithDelay(string: String) {
        coroutineScope.launch {
            delay(1000L)
            stringState = string
        }
    }
}

@OptIn(ExperimentalCoroutinesApi::class)
class StringStateHolderTestSuccess {

    @Test
    fun `updateStringWithDelay(ABC)가 호출되면 문자열이 ABC로 변경된다`() {
        // Given: 테스트용 디스패처 생성 및 주입
        val testDispatcher = StandardTestDispatcher()
        val stringStateHolder = StringStateHolder(dispatcher = testDispatcher)

        // When: 비동기 메서드 호출
        stringStateHolder.updateStringWithDelay("ABC")

        // Then: 가상 시간을 끝까지 전진시킨 후 결과 검증
        testDispatcher.scheduler.advanceUntilIdle()
        assertEquals("ABC", stringStateHolder.stringState)
    }
}
  • runTest를 호출해 실행되는 코루틴은 호출 쓰레드를 블로킹하며, 내부의 모든 코루틴이 실행 완료될 때까지 종료되지 않도록 한다.
  • runTest 코루틴 내부에서 새롭게 launch() 함수가 호출된 다음, 이 launch() 코루틴 내부에서 while문과 같이 무한히 실행되는 작업이 실행되면 runTest 코루틴은 끝나지 않고 계속해서 실행되기 때문에 테스트가 실패한다.
  • runTest 람다식의 수신 객체인 TestScope은 BackGroundScope를 제공한다.
  • BackGroundScope은 runTest 모든 코드가 실행되면 자동으로 취소되고 이를 통해 테스트가 무한히 실행되는 것을 방지할 수 있다.
@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineTimeoutTest {

    @Test
    fun `backgroundScope를 사용하여 타임아웃 해결하기`() = runTest {
        var result = 0

        // runTest가 제공하는 backgroundScope에서 launch를 실행합니다.
        // 이 코루틴은 테스트가 끝날 때 자동으로 cancel되므로 무한 루프여도 안전합니다.
        backgroundScope.launch {
            while (true) {
                delay(1000L)
                result += 1
            }
        }

        // 1. 가상 시간을 1.5초(1500ms) 전진 -> result가 1이 됨 (성공)
        advanceTimeBy(1500L)
        assertEquals(1, result)

        // 2. 가상 시간을 추가로 1초(1000ms) 전진 -> result가 2가 됨 (성공)
        advanceTimeBy(1000L)
        assertEquals(2, result)
        
        // 테스트 본문이 여기서 끝나면, runTest가 backgroundScope에 있던 
        // 무한 루프 코루틴을 알아서 취소시키며 테스트가 정상적으로 '성공' 종료됩니다.
    }
}