Тестирование сопрограмм Kotlin на Android

Код модульного тестирования, использующий сопрограммы , требует дополнительного внимания, поскольку их выполнение может быть асинхронным и происходить в нескольких потоках. В этом руководстве рассказывается о том, как можно тестировать приостанавливающие функции, о конструкциях тестирования, с которыми вам необходимо ознакомиться, и о том, как сделать код, использующий сопрограммы, тестируемым.

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 .

Дополнительные ресурсы