코루틴을 사용하는 단위 테스트 코드는 주의가 필요합니다. 비동기로 실행될 수 있고 여러 스레드에서 발생할 수 있기 때문입니다. 이 가이드에서는 정지 함수를 테스트하는 방법과 알아 두어야 하는 테스트 구성, 코루틴을 사용하는 코드를 테스트 가능하게 만드는 방법을 설명합니다.
이 가이드에서 사용한 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
구현에는 두 가지가 있습니다. StandardTestDispatcher
와 UnconfinedTestDispatcher
로, 이 두 가지는 새로 시작된 코루틴의 예약을 다르게 실행합니다. 둘 다 TestCoroutineScheduler
를 사용하여 가상 시간을 제어하고 테스트 내에서 실행 중인 코루틴을 관리합니다.
테스트에서 사용하는 스케줄러 인스턴스는 하나만 있어야 하며 모든 TestDispatchers
간에 공유되어야 합니다. 스케줄러 공유에 관한 자세한 내용은 TestDispatchers 삽입을 참고하세요.
최상위 테스트 코루틴을 시작하기 위해 runTest
는 TestScope
를 만듭니다. 이는 항상 TestDispatcher
를 사용하는 CoroutineScope
의 구현입니다. 지정하지 않으면 TestScope
는 기본적으로 StandardTestDispatcher
를 만들고 이를 사용하여 최상위 테스트 코루틴을 실행합니다.
runTest
는 TestScope
의 디스패처에서 사용하는 스케줄러의 대기열에 추가되는 코루틴을 추적하고 이 스케줄러에 대기 중인 작업이 있는 한 반환하지 않습니다.
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
내에서 TestScope
의 testScheduler
속성에 액세스하여 새로 만든 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.setMain
및 Dispatchers.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 ExampleRepository(private val ioDispatcher: CoroutineDispatcher) { /* ... */ } class RepositoryTestWithRule { private val repository = ExampleRepository(/* 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 = ExampleRepository(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... } }
테스트 내에서 만든 runTest
와 TestDispatchers
는 모두 Main
디스패처의 스케줄러를 자동으로 계속 공유합니다.
Main
디스패처를 교체하지 않는다면 새 스케줄러를 만드는 첫 번째 TestDispatcher
를 클래스의 속성으로 만듭니다. 그런 다음 이 스케줄러를 만들어진 각 runTest
호출과 새로운 각 TestDispatcher
에 모두 속성으로 그리고 테스트 내에서 전달합니다.
class RepositoryTest { // Creates the single test scheduler private val testDispatcher = UnconfinedTestDispatcher() private val repository = ExampleRepository(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 { // ... } }
위 코드는 암시적으로 TestScope
의 StandardTestDispatcher
를 만들고 새 스케줄러도 만듭니다. 이러한 객체는 모두 명시적으로 만들 수도 있습니다. 이는 종속 항목 삽입 설정과 통합해야 하는 경우 유용할 수 있습니다.
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 만들기를 참고하세요.