Android에서 Kotlin 코루틴 테스트

코루틴을 사용하는 단위 테스트 코드는 주의가 필요합니다. 비동기로 실행될 수 있고 여러 스레드에서 발생할 수 있기 때문입니다. 이 가이드에서는 정지 함수를 테스트하는 방법과 알아 두어야 하는 테스트 구성, 코루틴을 사용하는 코드를 테스트 가능하게 만드는 방법을 설명합니다.

이 가이드에서 사용한 API는 kotlinx.coroutines.test 라이브러리의 일부입니다. 이러한 API에 액세스하려면 테스트 종속 항목으로 프로젝트에 아티팩트를 추가해야 합니다.

dependencies {
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}

테스트에서 정지 함수 호출

테스트에서 정지 함수를 호출하려면 코루틴이 있어야 합니다. JUnit 테스트 함수 자체는 정지 함수가 아니므로 테스트 내에서 코루틴 빌더를 호출하여 새 코루틴을 시작해야 합니다.

runTest는 테스트용으로 설계된 코루틴 빌더입니다. 이를 통해 코루틴이 포함된 테스트를 래핑합니다. 코루틴은 테스트 본문에서 직접 시작할 수 있을 뿐 아니라 테스트에서 사용 중인 객체에서도 시작할 수 있습니다.

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

일반적으로 테스트당 runTest를 한 번 호출해야 하며 표현식 본문을 사용하는 것이 좋습니다.

runTest에서 테스트 코드를 래핑하면 기본 정지 함수를 테스트할 수 있고 코루틴의 지연을 자동으로 건너뛰므로 위의 테스트가 1초보다 훨씬 빠르게 완료됩니다.

그러나 테스트 중인 코드에서 발생하는 상황에 따라 추가로 고려해야 할 사항이 있습니다.

  • 코드가 runTest에서 만드는 최상위 테스트 코루틴 외에 새 코루틴을 만들 때는 적절한 TestDispatcher를 선택하여 새 코루틴이 예약되는 방식을 제어해야 합니다.
  • 코드가 코루틴 실행을 다른 디스패처로 이동하면(예: withContext 사용) runTest는 일반적으로 계속 작동하지만 지연을 더 이상 건너뛰지 않으며 테스트는 코드가 여러 스레드에서 실행되므로 예측 가능성이 떨어집니다. 이러한 이유로 테스트에서 실제 디스패처를 교체하려면 테스트 디스패처를 삽입해야 합니다.

TestDispatchers

TestDispatchers는 테스트 목적으로 사용하는 CoroutineDispatcher 구현입니다. 새 코루틴의 실행을 예측할 수 있도록 테스트 중에 새 코루틴을 만드는 경우 TestDispatchers를 사용해야 합니다.

사용할 수 있는 TestDispatcher 구현에는 두 가지가 있습니다. StandardTestDispatcherUnconfinedTestDispatcher로, 이 두 가지는 새로 시작된 코루틴의 예약을 다르게 실행합니다. 둘 다 TestCoroutineScheduler를 사용하여 가상 시간을 제어하고 테스트 내에서 실행 중인 코루틴을 관리합니다.

테스트에서 사용하는 스케줄러 인스턴스는 하나만 있어야 하며 모든 TestDispatchers 간에 공유되어야 합니다. 스케줄러 공유에 관한 자세한 내용은 TestDispatchers 삽입을 참고하세요.

최상위 테스트 코루틴을 시작하기 위해 runTestTestScope를 만듭니다. 이는 항상 TestDispatcher를 사용하는 CoroutineScope의 구현입니다. 지정하지 않으면 TestScope는 기본적으로 StandardTestDispatcher를 만들고 이를 사용하여 최상위 테스트 코루틴을 실행합니다.

runTestTestScope의 디스패처에서 사용하는 스케줄러의 대기열에 추가되는 코루틴을 추적하고 이 스케줄러에 대기 중인 작업이 있는 한 반환하지 않습니다.

StandardTestDispatcher

StandardTestDispatcher에서 새 코루틴을 시작하면 코루틴이 기본 스케줄러의 대기열에 추가되어 테스트 스레드를 사용할 수 있을 때마다 실행됩니다. 이러한 새 코루틴이 실행되도록 하려면 테스트 스레드를 생성해야 합니다(다른 코루틴에서 사용할 수 있도록 해제). 이러한 대기열에 추가되는 동작을 통해 테스트 중에 새 코루틴이 실행되는 방식을 정밀하게 제어할 수 있으며 이 동작은 프로덕션 코드의 코루틴 예약과 유사합니다.

테스트 스레드가 최상위 테스트 코루틴을 실행하는 동안 생성되지 않으면, 모든 새 코루틴은 테스트 코루틴이 완료된 후(그러나 runTest가 반환하기 전)에만 실행됩니다.

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

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

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

대기열에 추가된 코루틴이 실행되도록 테스트 코루틴을 생성하는 방법에는 여러 가지가 있습니다. 이러한 모든 호출은 반환하기 전에 테스트 스레드에서 다른 코루틴이 실행되도록 합니다.

  • advanceUntilIdle: 대기열에 남은 항목이 없을 때까지 스케줄러에서 다른 코루틴을 모두 실행합니다. 이는 대기 중인 코루틴이 모두 실행되도록 할 수 있는 좋은 방법이며 대부분의 테스트 시나리오에서 작동합니다.
  • advanceTimeBy: 주어진 양만큼 가상 시간을 진행하고 가상 시간의 해당 지점 전에 실행되도록 예약된 코루틴을 실행합니다.
  • runCurrent: 현재 가상 시간에 예약된 코루틴을 실행합니다.

이전 테스트를 수정하려면 어설션으로 넘어가기 전에 advanceUntilIdle을 사용하여 대기 중인 코루틴 두 개가 작업을 실행하도록 하면 됩니다.

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

    launch { userRepo.register("Alice") }
    launch { userRepo.register("Bob") }
    advanceUntilIdle() // Yields to perform the registrations

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

UnconfinedTestDispatcher

새 코루틴이 UnconfinedTestDispatcher에서 시작되면 현재 스레드에서 빠르게 시작됩니다. 즉, 코루틴 빌더가 반환될 때까지 기다리지 않고 즉시 실행됩니다. 대부분의 경우 이 디스패치 동작으로 인해 테스트 코드가 간단해집니다. 새 코루틴이 실행되도록 테스트 스레드를 수동으로 생성하지 않아도 되기 때문입니다.

그러나 이 동작은 비테스트 디스패처를 사용할 때 프로덕션에 표시되는 것과는 다릅니다. 테스트에서 동시 실행에 중점을 둔다면 대신 StandardTestDispatcher를 사용하는 것이 좋습니다.

이 디스패처를 runTest의 최상위 테스트 코루틴에 기본 디스패처 대신 사용하려면 인스턴스를 만들어 매개변수로 전달합니다. 이렇게 하면 runTest 내에서 생성된 새 코루틴이 빠르게 실행됩니다. TestScope에서 디스패처를 상속받기 때문입니다.

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

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

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

이 예시에서 launch 호출은 UnconfinedTestDispatcher에서 빠르게 새 코루틴을 시작합니다. 즉, 각 launch 호출은 등록이 완료된 후에만 반환합니다.

UnconfinedTestDispatcher는 새 코루틴을 빠르게 시작하지만 그렇다고 해서 완료될 때까지 빠르게 실행하는 것은 아닙니다. 새 코루틴이 정지되면 다른 코루틴이 실행을 다시 시작합니다.

예를 들어 이 테스트 내에서 실행된 새 코루틴은 Alice를 등록하지만 delay가 호출되면 정지됩니다. 이를 통해 최상위 코루틴이 어설션을 계속 진행할 수 있고 테스트는 Bob이 아직 등록되지 않았으므로 실패합니다.

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

    launch {
        userRepo.register("Alice")
        delay(10L)
        userRepo.register("Bob")
    }

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

테스트 디스패처 삽입

테스트 중인 코드는 디스패처를 사용하여 스레드를 전환하거나(withContext 사용) 새 코루틴을 시작할 수 있습니다. 코드가 여러 스레드에서 동시에 실행되면 테스트가 불안정해질 수 있습니다. 개발자가 제어할 수 없는 백그라운드 스레드에서 작업이 실행되는 경우 어설션을 정확한 시간에 실행하거나 작업이 완료되도록 기다리는 것이 어려울 수 있습니다.

테스트에서 이러한 디스패처를 TestDispatchers의 인스턴스로 바꿉니다. 이렇게 하면 다음과 같은 몇 가지 장점이 있습니다.

  • 코드가 단일 테스트 스레드에서 실행되므로 테스트의 확정성이 높아집니다.
  • 새 코루틴의 예약 및 실행 방법을 제어할 수 있습니다.
  • TestDispatchers는 가상 시간용 스케줄러를 사용하여 자동으로 지연을 건너뛰고 개발자가 수동으로 시간을 앞당기도록 합니다.

종속 항목 삽입을 사용하여 클래스에 디스패처를 제공하면 테스트에서 실제 디스패처를 쉽게 대체할 수 있습니다. 이 예에서는 CoroutineDispatcher를 삽입하지만 더 넓은 CoroutineContext 유형을 삽입할 수도 있습니다. 이렇게 하면 테스트 중 유연성이 더욱 높아집니다.

코루틴을 시작하는 클래스의 경우 범위 삽입 섹션에 설명된 대로 디스패처 대신 CoroutineScope를 삽입할 수도 있습니다.

TestDispatchers는 기본적으로 인스턴스화될 때 새 스케줄러를 만듭니다. runTest 내에서 TestScopetestScheduler 속성에 액세스하여 새로 만든 TestDispatchers에 전달할 수 있습니다. 그러면 가상 시간에 관한 이해가 공유되고 advanceUntilIdle과 같은 메서드가 모든 테스트 디스패처에서 코루틴을 완료될 때까지 실행합니다.

다음 예시에서는 initialize 메서드에서 IO 디스패처를 사용하여 새 코루틴을 만들고 fetchData 메서드에서 호출자를 IO 디스패처로 전환하는 Repository 클래스를 확인할 수 있습니다.

// Example class demonstrating dispatcher use cases
class Repository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)
    val initialized = AtomicBoolean(false)

    // A function that starts a new coroutine on the IO dispatcher
    fun initialize() {
        scope.launch {
            initialized.set(true)
        }
    }

    // A suspending function that switches to the IO dispatcher
    suspend fun fetchData(): String = withContext(ioDispatcher) {
        require(initialized.get()) { "Repository should be initialized first" }
        delay(500L)
        "Hello world"
    }
}

테스트에서 TestDispatcher 구현을 삽입하여 IO 디스패처를 대체할 수 있습니다.

아래 예시에서는 저장소에 StandardTestDispatcher를 삽입하고 advanceUntilIdle을 사용하여 계속 진행하기 전에 initialize에서 시작된 새 코루틴이 완료되도록 합니다.

fetchData는 테스트 스레드에서 실행되며 테스트 중에 포함된 지연을 건너뛰므로 TestDispatcher에서 실행하는 것이 좋습니다.

class RepositoryTest {
    @Test
    fun repoInitWorksAndDataIsHelloWorld() = runTest {
        val dispatcher = StandardTestDispatcher(testScheduler)
        val repository = Repository(dispatcher)

        repository.initialize()
        advanceUntilIdle() // Runs the new coroutine
        assertEquals(true, repository.initialized.get())

        val data = repository.fetchData() // No thread switch, delay is skipped
        assertEquals("Hello world", data)
    }
}

TestDispatcher에서 시작된 새 코루틴은 위와 같이 initialize를 사용하여 수동으로 진행할 수 있습니다. 그러나 프로덕션 코드에서는 불가능하거나 바람직하지 않습니다. 대신 이 메서드는 정지되거나(순차 실행의 경우) 또는 Deferred 값을 반환하도록(동시 실행의 경우) 다시 설계되어야 합니다.

예를 들어 async를 사용하여 새 코루틴을 시작하고 Deferred를 만들 수 있습니다.

class BetterRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
    private val scope = CoroutineScope(ioDispatcher)

    fun initialize() = scope.async {
        // ...
    }
}

이를 통해 테스트와 프로덕션 코드에서 모두 이 코드의 완성을 안전하게 await할 수 있습니다.

@Test
fun repoInitWorks() = runTest {
    val dispatcher = StandardTestDispatcher(testScheduler)
    val repository = BetterRepository(dispatcher)

    repository.initialize().await() // Suspends until the new coroutine is done
    assertEquals(true, repository.initialized.get())
    // ...
}

runTest는 코루틴이 스케줄러를 공유하는 TestDispatcher에 있는 경우 반환하기 전에 대기 중인 코루틴이 완료될 때까지 기다립니다. 최상위 테스트 코루틴의 하위 요소인 코루틴도 기다립니다. 다른 디스패처에 있는 경우에도(dispatchTimeoutMs 매개변수로 지정된 제한 시간까지. 기본값은 60초) 마찬가지입니다.

Main 디스패처 설정

로컬 단위 테스트에서는 Android UI 스레드를 래핑하는 Main 디스패처를 사용할 수 없습니다. 이러한 테스트는 Android 기기가 아닌 로컬 JVM에서 실행되기 때문입니다. 테스트 중인 코드가 기본 스레드를 참조하면 단위 테스트 중에 예외가 발생합니다.

때에 따라 이전 섹션에서 설명한 대로 다른 디스패처와 같은 방식으로 Main 디스패처를 삽입하여 테스트에서 이를 TestDispatcher로 교체할 수 있습니다. 그러나 viewModelScope와 같은 일부 API는 내부적으로 하드코딩된 Main 디스패처를 사용합니다.

다음은 데이터를 로드하는 코루틴을 실행하는 데 viewModelScope를 사용하는 ViewModel 구현을 보여주는 예시입니다.

class HomeViewModel : ViewModel() {
    private val _message = MutableStateFlow("")
    val message: StateFlow<String> get() = _message

    fun loadMessage() {
        viewModelScope.launch {
            _message.value = "Greetings!"
        }
    }
}

모든 경우에 Main 디스패처를 TestDispatcher로 바꾸려면 Dispatchers.setMainDispatchers.resetMain 함수를 사용합니다.

class HomeViewModelTest {
    @Test
    fun settingMainDispatcher() = runTest {
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)
        Dispatchers.setMain(testDispatcher)

        try {
            val viewModel = HomeViewModel()
            viewModel.loadMessage() // Uses testDispatcher, runs its coroutine eagerly
            assertEquals("Greetings!", viewModel.message.value)
        } finally {
            Dispatchers.resetMain()
        }
    }
}

Main 디스패처가 TestDispatcher로 교체되었다면 새로 만들어진 TestDispatchers는 자동으로 Main 디스패처의 스케줄러를 사용합니다. 여기에는 전달된 다른 디스패처가 없는 경우 runTest에서 만든 StandardTestDispatcher도 포함됩니다.

이렇게 하면 테스트하는 동안 스케줄러가 하나만 사용되는지 쉽게 확인할 수 있습니다. 이 작업을 실행하려면 Dispatchers.setMain을 호출한 후에 다른 모든 TestDispatcher 인스턴스를 만들어야 합니다.

각 테스트에서 Main 디스패처를 교체하는 코드가 중복되지 않도록 하는 일반적인 패턴은 코드를 JUnit 테스트 규칙으로 추출하는 것입니다.

// Reusable JUnit4 TestRule to override the Main dispatcher
class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest { // Uses Main’s scheduler
        val viewModel = HomeViewModel()
        viewModel.loadMessage()
        assertEquals("Greetings!", viewModel.message.value)
    }
}

이 규칙 구현에서는 기본적으로 UnconfinedTestDispatcher를 사용하지만 Main 디스패처가 지정된 테스트 클래스에서 빠르게 실행되지 않아야 하는 경우 StandardTestDispatcher를 매개변수로 전달할 수 있습니다.

TestDispatcher 인스턴스가 테스트 본문에 필요하다면 원하는 유형인 경우 규칙에서 testDispatcher를 재사용할 수 있습니다. 테스트에서 사용된 TestDispatcher 유형에 관해 명시적이려면 또는 Main에 사용된 것과 다른 유형인 TestDispatcher가 필요한 경우 runTest 내에서 새 TestDispatcher를 만들면 됩니다. Main 디스패처가 TestDispatcher로 설정되어 있으므로 새로 만들어진 TestDispatchers는 스케줄러를 자동으로 공유합니다.

class DispatcherTypesTest {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun injectingTestDispatchers() = runTest { // Uses Main’s scheduler
        // Use the UnconfinedTestDispatcher from the Main dispatcher
        val unconfinedRepo = Repository(mainDispatcherRule.testDispatcher)

        // Create a new StandardTestDispatcher (uses Main’s scheduler)
        val standardRepo = Repository(StandardTestDispatcher())
    }
}

테스트 외부에서 디스패처 만들기

테스트 메서드 외부에서 TestDispatcher를 사용할 수 있어야 할 수도 있습니다. 테스트 클래스의 속성을 초기화하는 동안을 예로 들 수 있습니다.

class Repository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ }

class RepositoryTestWithRule {
    private val repository = Repository(/* What TestDispatcher? */)

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun someRepositoryTest() = runTest {
        // Test the repository...
        // ...
    }
}

이전 섹션과 같이 Main 디스패처를 교체한다면 Main 디스패처가 교체된 만들어진 TestDispatchers는 자동으로 스케줄러를 공유합니다.

그러나 테스트 클래스의 속성으로 만들어진 TestDispatchers 또는 테스트 클래스의 속성을 초기화하는 동안 만들어진 TestDispatchers의 경우는 그렇지 않습니다. 이는 Main 디스패처가 교체되기 전에 초기화됩니다. 따라서 새 스케줄러를 만듭니다.

테스트에 스케줄러가 하나만 있도록 하려면 먼저 MainDispatcherRule 속성을 만듭니다. 그런 다음 필요에 따라 다른 클래스 수준 속성의 초기화 프로그램에서 디스패처(또는 다른 유형의 TestDispatcher가 필요한 경우 스케줄러)를 재사용합니다.

class RepositoryTestWithRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repository = Repository(mainDispatcherRule.testDispatcher)

    @Test
    fun someRepositoryTest() = runTest { // Takes scheduler from Main
        // Any TestDispatcher created here also takes the scheduler from Main
        val newTestDispatcher = StandardTestDispatcher()

        // Test the repository...
    }
}

테스트 내에서 만든 runTestTestDispatchers는 모두 Main 디스패처의 스케줄러를 자동으로 계속 공유합니다.

Main 디스패처를 교체하지 않는다면 새 스케줄러를 만드는 첫 번째 TestDispatcher를 클래스의 속성으로 만듭니다. 그런 다음 이 스케줄러를 만들어진 각 runTest 호출과 새로운 각 TestDispatcher에 모두 속성으로 그리고 테스트 내에서 전달합니다.

class RepositoryTest {
    // Creates the single test scheduler
    private val testDispatcher = UnconfinedTestDispatcher()
    private val repository = Repository(testDispatcher)

    @Test
    fun someRepositoryTest() = runTest(testDispatcher.scheduler) {
        // Take the scheduler from the TestScope
        val newTestDispatcher = UnconfinedTestDispatcher(this.testScheduler)
        // Or take the scheduler from the first dispatcher, they’re the same
        val anotherTestDispatcher = UnconfinedTestDispatcher(testDispatcher.scheduler)

        // Test the repository...
    }
}

이 샘플에서는 첫 번째 디스패처의 스케줄러가 runTest에 전달됩니다. 그러면 해당 스케줄러를 사용하여 TestScope의 새 StandardTestDispatcher가 만들어집니다. 디스패처를 runTest에 직접 전달하여 이 디스패처에서 테스트 코루틴을 실행할 수도 있습니다.

자체 TestScope 만들기

TestDispatchers와 마찬가지로 테스트 본문 외부의 TestScope에 액세스해야 할 수 있습니다. runTest는 내부적으로 TestScope를 자동으로 만들지만 runTest와 함께 사용할 자체 TestScope를 만들 수도 있습니다.

이럴 때는 직접 만든 TestScope에서 runTest를 호출해야 합니다.

class SimpleExampleTest {
    val testScope = TestScope() // Creates a StandardTestDispatcher

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

위 코드는 암시적으로 TestScopeStandardTestDispatcher를 만들고 새 스케줄러도 만듭니다. 이러한 객체는 모두 명시적으로 만들 수도 있습니다. 이는 종속 항목 삽입 설정과 통합해야 하는 경우 유용할 수 있습니다.

class ExampleTest {
    val testScheduler = TestCoroutineScheduler()
    val testDispatcher = StandardTestDispatcher(testScheduler)
    val testScope = TestScope(testDispatcher)

    @Test
    fun someTest() = testScope.runTest {
        // ...
    }
}

범위 삽입

테스트 중에 제어해야 하는 코루틴을 생성하는 클래스가 있다면 코루틴 범위를 이 클래스에 삽입하여 테스트에서 TestScope로 대체할 수 있습니다.

다음 예에서 UserState 클래스는 UserRepository에 따라 신규 사용자를 등록하고 등록된 사용자 목록을 가져옵니다. 이러한 UserRepository 호출이 함수 호출을 정지하므로 UserState는 삽입된 CoroutineScope를 사용하여 registerUser 함수 내에서 새 코루틴을 시작합니다.

class UserState(
    private val userRepository: UserRepository,
    private val scope: CoroutineScope,
) {
    private val _users = MutableStateFlow(emptyList<String>())
    val users: StateFlow<List<String>> = _users.asStateFlow()

    fun registerUser(name: String) {
        scope.launch {
            userRepository.register(name)
            _users.update { userRepository.getAllUsers() }
        }
    }
}

이 클래스를 테스트하려면 UserState 객체를 만들 때 runTest에서 TestScope를 전달하면 됩니다.

class UserStateTest {
    @Test
    fun addUserTest() = runTest { // this: TestScope
        val repository = FakeUserRepository()
        val userState = UserState(repository, scope = this)

        userState.registerUser("Mona")
        advanceUntilIdle() // Let the coroutine complete and changes propagate

        assertEquals(listOf("Mona"), userState.users.value)
    }
}

테스트 함수의 범위를 벗어난 범위를 삽입하려면(예: 테스트 클래스의 속성으로 생성된 테스트에서 객체에 삽입) 자체 TestScope 만들기를 참고하세요.

추가 리소스