Код модульного тестирования, использующий сопрограммы , требует дополнительного внимания, поскольку их выполнение может быть асинхронным и происходить в нескольких потоках. В этом руководстве рассказывается о том, как можно тестировать приостанавливающие функции, о конструкциях тестирования, с которыми вам необходимо ознакомиться, и о том, как сделать код, использующий сопрограммы, тестируемым.
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
будет работать для тестирования основных функций приостановки и автоматически пропустит любые задержки в сопрограммах, благодаря чему приведенный выше тест будет завершен намного быстрее, чем за одну секунду.
Однако есть дополнительные соображения, которые следует учитывать в зависимости от того, что происходит в тестируемом коде:
- Когда ваш код создает новые сопрограммы, отличные от тестовой сопрограммы верхнего уровня, которую создает
runTest
, вам нужно будет контролировать, как планируются эти новые сопрограммы , выбирая соответствующийTestDispatcher
. - Если ваш код переносит выполнение сопрограммы на другие диспетчеры (например, с помощью
withContext
),runTest
по-прежнему будет работать, но задержки больше не будут пропускаться, а тесты будут менее предсказуемыми, поскольку код выполняется в нескольких потоках. По этим причинам в тесты следует внедрять тестовые диспетчеры для замены реальных диспетчеров.
Диспетчеры тестирования
TestDispatchers
— это реализация CoroutineDispatcher
для целей тестирования. Вам понадобится использовать TestDispatchers
если во время теста создаются новые сопрограммы, чтобы сделать выполнение новых сопрограмм предсказуемым.
Существует две доступные реализации TestDispatcher
: StandardTestDispatcher
и UnconfinedTestDispatcher
, которые выполняют различное планирование вновь запускаемых сопрограмм. Оба они используют TestCoroutineScheduler
для управления виртуальным временем и управлением запуском сопрограмм в тесте.
В тесте должен использоваться только один экземпляр планировщика , общий для всех TestDispatchers
. См. раздел «Внедрение TestDispatchers» , чтобы узнать о совместном использовании планировщиков.
Чтобы запустить сопрограмму тестирования верхнего уровня, runTest
создает TestScope
, который является реализацией CoroutineScope
, которая всегда будет использовать TestDispatcher
. Если не указано, TestScope
по умолчанию создаст StandardTestDispatcher
и будет использовать его для запуска тестовой сопрограммы верхнего уровня.
runTest
отслеживает сопрограммы, которые поставлены в очередь в планировщике, используемом диспетчером его TestScope
, и не возвращается, пока в этом планировщике есть ожидающая работа.
СтандартТестДиспетчер
Когда вы запускаете новые сопрограммы в 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 }
В этом примере вызовы запуска будут активно запускать свои новые сопрограммы в UnconfinedTestDispatcher
, а это означает, что каждый вызов запуска будет возвращаться только после завершения регистрации.
Помните, что UnconfinedTestDispatcher
с нетерпением запускает новые сопрограммы, но это не означает, что он также с нетерпением запускает их до завершения. Если новая сопрограмма приостанавливается, другие сопрограммы возобновят выполнение.
Например, новая сопрограмма, запущенная в рамках этого теста, зарегистрирует Алису, но затем приостанавливается при вызове delay
. Это позволяет сопрограмме верхнего уровня продолжить утверждение, и тест не пройден, поскольку Боб еще не зарегистрирован:
@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
. Это имеет несколько преимуществ:
- Код будет выполняться в одном тестовом потоке, что сделает тесты более детерминированными.
- Вы можете контролировать, как планируются и выполняются новые сопрограммы.
- TestDispatcher использует планировщик виртуального времени, который автоматически пропускает задержки и позволяет заранее сдвигать время вручную.
Использование внедрения зависимостей для предоставления диспетчеров вашим классам позволяет легко заменить настоящие диспетчеры в тестах. В этих примерах мы внедрим CoroutineDispatcher
, но вы также можете внедрить более широкий тип CoroutineContext
, который обеспечивает еще большую гибкость во время тестов.
Для классов, запускающих сопрограммы, вы также можете внедрить CoroutineScope
вместо диспетчера, как подробно описано в разделе «Внедрение области» .
TestDispatchers
по умолчанию создает новый планировщик при создании экземпляра. Внутри runTest
вы можете получить доступ к свойству testScheduler
объекта TestScope
и передать его в любой вновь созданный TestDispatchers
. Это поделится их пониманием виртуального времени, а такие методы, как advanceUntilIdle
будут запускать сопрограммы на всех диспетчерах тестирования до завершения.
В следующем примере вы можете увидеть класс Repository
, который создает новую сопрограмму с использованием диспетчера IO
в его методе initialize
и переключает вызывающую сторону на диспетчер IO
в его методе fetchData
:
// 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, будет недоступен, поскольку эти тесты выполняются на локальной JVM, а не на устройстве Android. Если ваш тестируемый код ссылается на основной поток, во время модульных тестов он выдаст исключение.
В некоторых случаях вы можете внедрить Main
диспетчер так же, как и другие диспетчеры, как описано в предыдущем разделе , что позволит вам заменить его на TestDispatcher
в тестах. Однако некоторые API, такие как viewModelScope
используют жестко запрограммированный Main
диспетчер.
Вот пример реализации ViewModel
, которая использует viewModelScope
для запуска сопрограммы, загружающей данные:
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
диспетчера , включая StandardTestDispatcher
, созданный runTest
, если ему не передан другой диспетчер.
Это упрощает обеспечение использования только одного планировщика во время теста. Чтобы это работало, обязательно создайте все остальные экземпляры TestDispatcher
после вызова Dispatchers.setMain
.
Распространенным шаблоном, позволяющим избежать дублирования кода, заменяющего 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
, но StandardTestDispatcher
можно передать в качестве параметра, если Main
диспетчер не должен быстро выполняться в данном тестовом классе.
Если вам нужен экземпляр TestDispatcher
в теле теста, вы можете повторно использовать testDispatcher
из правила, если он имеет желаемый тип. Если вы хотите явно указать тип TestDispatcher
, используемый в тесте, или если вам нужен TestDispatcher
, тип которого отличается от того, который используется для Main
, вы можете создать новый TestDispatcher
внутри runTest
. Поскольку в качестве 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
диспетчер, как показано в предыдущем разделе, TestDispatchers
, созданные после замены Main
диспетчера, автоматически будут использовать его планировщик.
Однако это не относится к 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
. Это создаст новый StandardTestDispatcher
для TestScope
с использованием этого планировщика. Вы также можете передать диспетчер непосредственно в runTest
, чтобы запустить тестовую сопрограмму в этом диспетчере.
Создание собственного TestScope
Как и в случае с TestDispatchers
, вам может потребоваться доступ к TestScope
вне тела теста. Хотя runTest
автоматически создает TestScope
, вы также можете создать свой собственный TestScope
для использования с runTest
.
При этом обязательно вызовите runTest
в созданном вами TestScope
:
class SimpleExampleTest { val testScope = TestScope() // Creates a StandardTestDispatcher @Test fun someTest() = testScope.runTest { // ... } }
Приведенный выше код неявно создает StandardTestDispatcher
для TestScope
, а также новый планировщик. Все эти объекты также могут быть созданы явно. Это может быть полезно, если вам нужно интегрировать его с настройками внедрения зависимостей.
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() } } } }
Чтобы протестировать этот класс, вы можете передать TestScope
из runTest
при создании объекта UserState
:
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 .