При модульном тестировании кода, в котором используются корутины, нужно быть особенно внимательным, поскольку они могут выполняться асинхронно и в нескольких потоках. В этом руководстве рассказывается, как тестировать приостанавливаемые функции, какие конструкции тестирования вам нужно знать и как сделать код, использующий сопрограммы, пригодным для тестирования.
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
TestDispatchers – это реализации CoroutineDispatcher для тестирования. Если во время тестирования создаются новые сопрограммы, вам нужно использовать TestDispatchers, чтобы их выполнение было предсказуемым.
Существует две реализации TestDispatcher: StandardTestDispatcher и UnconfinedTestDispatcher, которые по-разному планируют запуск новых сопрограмм. Оба этих метода используют TestCoroutineScheduler для управления виртуальным временем и запущенными сопрограммами в тесте.
В тесте должен использоваться только один экземпляр планировщика, общий для всех TestDispatchers. Подробнее о том, как внедрять TestDispatchers…
Чтобы запустить сопрограмму тестирования верхнего уровня, runTest создает TestScope, который является реализацией CoroutineScope и всегда будет использовать TestDispatcher. Если не указано, 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 }
В этом примере вызовы запуска будут нетерпеливо начинать новые сопрограммы на 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. Это дает следующие преимущества:
- Код будет выполняться в одном потоке тестирования, что повысит детерминированность тестов.
- Вы можете контролировать, как планируются и выполняются новые сопрограммы.
- TestDispatchers используют планировщик для виртуального времени, который автоматически пропускает задержки и позволяет вручную перематывать время.
Используя внедрение зависимостей для предоставления диспетчеров классам, вы можете легко заменить реальных диспетчеров в тестах. В этих примерах мы будем вставлять 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, который оборачивает поток UI Android, будет недоступен, поскольку эти тесты выполняются в локальной виртуальной машине Java, а не на устройстве 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() } } } }
Чтобы протестировать этот класс, при создании объекта UserState можно передать TestScope из runTest:
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) } }
Чтобы внедрить область действия за пределами функции тестирования, например в объект под тестом, который создан как свойство в классе тестирования, ознакомьтесь с разделом Создание собственной области действия теста.