681 lines
35 KiB
Markdown
681 lines
35 KiB
Markdown
---
|
||
title: Тестирование корутин
|
||
description: runTest, виртуальное время, TestDispatcher, backgroundScope, тестирование Flow и Turbine
|
||
tags:
|
||
- kotlin
|
||
- testing
|
||
- coroutines
|
||
---
|
||
|
||
Корутины ломают привычную модель теста: результат появляется не в момент вызова, а когда-то позже, а `delay` делает тест медленным. Библиотека `kotlinx-coroutines-test` решает обе проблемы - она даёт `runTest` с виртуальным временем, где `delay(10.seconds)` выполняется мгновенно.
|
||
|
||
```kotlin
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.test.currentTime
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.time.Duration.Companion.seconds
|
||
|
||
class FirstCoroutineTest {
|
||
@Test
|
||
fun `delay не замедляет тест`() = runTest {
|
||
val start = currentTime
|
||
delay(10.seconds)
|
||
assertEquals(10_000, currentTime - start)
|
||
}
|
||
}
|
||
```
|
||
Тест завершается за миллисекунды, хотя по виртуальным часам прошло десять секунд.
|
||
|
||
## Подключение
|
||
```kotlin
|
||
// build.gradle.kts
|
||
dependencies {
|
||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
|
||
testImplementation("app.cash.turbine:turbine:1.2.1")
|
||
}
|
||
|
||
tasks.test { useJUnitPlatform() }
|
||
```
|
||
| Артефакт | Зачем |
|
||
|---|---|
|
||
| `kotlinx-coroutines-test` | `runTest`, `TestDispatcher`, `TestScope` |
|
||
| `app.cash.turbine:turbine` | удобная проверка `Flow` по элементам |
|
||
| `kotlinx-coroutines-debug` | дампы корутин при зависании |
|
||
|
||
Версия `kotlinx-coroutines-test` должна совпадать с версией `kotlinx-coroutines-core` - иначе возможен `NoSuchMethodError` в рантайме.
|
||
|
||
## runTest
|
||
```kotlin
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.advanceTimeBy
|
||
import kotlinx.coroutines.test.advanceUntilIdle
|
||
import kotlinx.coroutines.test.runCurrent
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class RunTestControlTest {
|
||
@Test
|
||
fun `runCurrent выполняет уже запланированное`() = runTest {
|
||
val log = mutableListOf<String>()
|
||
launch { log += "сразу" }
|
||
// корутина запланирована, но ещё не запущена
|
||
assertEquals(emptyList(), log)
|
||
runCurrent()
|
||
assertEquals(listOf("сразу"), log)
|
||
}
|
||
|
||
@Test
|
||
fun `advanceTimeBy продвигает часы на заданное время`() = runTest {
|
||
val log = mutableListOf<String>()
|
||
launch {
|
||
delay(100); log += "a"
|
||
delay(100); log += "b"
|
||
}
|
||
advanceTimeBy(150)
|
||
assertEquals(listOf("a"), log)
|
||
advanceUntilIdle()
|
||
assertEquals(listOf("a", "b"), log)
|
||
}
|
||
|
||
@Test
|
||
fun `testScheduler доступен напрямую`() = runTest {
|
||
assertEquals(0, testScheduler.currentTime)
|
||
delay(5)
|
||
assertEquals(5, testScheduler.currentTime)
|
||
}
|
||
}
|
||
```
|
||
| Инструмент | Что делает |
|
||
|---|---|
|
||
| `runTest { }` | запускает тело как корутину с виртуальным временем |
|
||
| `currentTime` | виртуальные часы в миллисекундах |
|
||
| `runCurrent()` | выполняет задачи, запланированные на текущий момент |
|
||
| `advanceTimeBy(ms)` | продвигает часы, выполняя всё по пути |
|
||
| `advanceUntilIdle()` | продвигает часы до исчерпания задач |
|
||
| `testScheduler` | планировщик, общий для диспетчеров теста |
|
||
| `backgroundScope` | scope, отменяемый по завершении теста |
|
||
| `runTest(timeout = 5.seconds)` | реальный таймаут на весь тест, по умолчанию 60 с |
|
||
|
||
`runTest` возвращает `TestResult`, поэтому тело теста пишется как `= runTest { }`, а не `{ runTest { } }` - на JVM это `Unit`, но форма с `=` работает и в Multiplatform.
|
||
|
||
После выхода из тела `runTest` ждёт завершения всех дочерних корутин, запущенных в его scope. Это именно то, что нужно в большинстве тестов: явный `advanceUntilIdle` в конце не требуется.
|
||
|
||
## Диспетчеры теста
|
||
```kotlin
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||
import kotlinx.coroutines.test.advanceUntilIdle
|
||
import kotlinx.coroutines.test.runCurrent
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class DispatchersTest {
|
||
@Test
|
||
fun `StandardTestDispatcher откладывает запуск`() = runTest(StandardTestDispatcher()) {
|
||
val log = mutableListOf<String>()
|
||
launch { log += "дочерняя" }
|
||
assertEquals(emptyList(), log)
|
||
runCurrent()
|
||
assertEquals(listOf("дочерняя"), log)
|
||
}
|
||
|
||
@Test
|
||
fun `UnconfinedTestDispatcher запускает немедленно`() = runTest(UnconfinedTestDispatcher()) {
|
||
val log = mutableListOf<String>()
|
||
launch { log += "дочерняя" }
|
||
assertEquals(listOf("дочерняя"), log)
|
||
}
|
||
|
||
@Test
|
||
fun `общий scheduler связывает диспетчеры`() = runTest {
|
||
val other = StandardTestDispatcher(testScheduler)
|
||
val log = mutableListOf<String>()
|
||
launch(other) { delay(100); log += "другой" }
|
||
advanceUntilIdle()
|
||
assertEquals(listOf("другой"), log)
|
||
}
|
||
}
|
||
```
|
||
| Диспетчер | Поведение |
|
||
|---|---|
|
||
| `StandardTestDispatcher` | корутина ставится в очередь, нужен `runCurrent` или `advanceUntilIdle` |
|
||
| `UnconfinedTestDispatcher` | корутина выполняется до первой приостановки немедленно |
|
||
|
||
`runTest` без аргумента использует `StandardTestDispatcher` - это осознанный выбор: отложенный запуск приближает тест к реальному многопоточному исполнению и находит гонки, которые `Unconfined` скрывает.
|
||
|
||
`UnconfinedTestDispatcher` удобен, когда проверяется результат, а не порядок - особенно при сборе `StateFlow`, где конфлюирование съедает промежуточные значения.
|
||
|
||
Главное про `scheduler`: виртуальные часы живут в нём, а не в диспетчере. Диспетчер, созданный **без** передачи `testScheduler`, получает свой собственный планировщик, и `advanceUntilIdle` теста на него не действует.
|
||
|
||
## backgroundScope
|
||
```kotlin
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.advanceTimeBy
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class BackgroundScopeTest {
|
||
@Test
|
||
fun `бесконечная корутина не подвешивает тест`() = runTest {
|
||
var ticks = 0
|
||
backgroundScope.launch {
|
||
while (true) {
|
||
delay(100)
|
||
ticks++
|
||
}
|
||
}
|
||
advanceTimeBy(350)
|
||
assertEquals(3, ticks)
|
||
}
|
||
}
|
||
```
|
||
`runTest` ждёт дочерние корутины своего scope, поэтому бесконечный цикл в `launch { }` приведёт к `UncompletedCoroutinesError` по таймауту. `backgroundScope` отменяется автоматически по завершении теста - это единственно верное место для долгоживущих коллекторов, подписок и имитации фоновой работы.
|
||
|
||
## Внедрение диспетчера
|
||
Виртуальное время работает только для диспетчеров, связанных с `testScheduler`. Жёстко прописанный `Dispatchers.IO` в продакшн-коде делает тест реально медленным:
|
||
```kotlin
|
||
import kotlinx.coroutines.CoroutineDispatcher
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.withContext
|
||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertTrue
|
||
|
||
class Hardcoded {
|
||
suspend fun work(): String = withContext(Dispatchers.IO) {
|
||
delay(1000)
|
||
"результат"
|
||
}
|
||
}
|
||
|
||
class Injected(private val io: CoroutineDispatcher) {
|
||
suspend fun work(): String = withContext(io) {
|
||
delay(1000)
|
||
"результат"
|
||
}
|
||
}
|
||
|
||
class DispatcherInjectionTest {
|
||
@Test
|
||
fun `внедрённый диспетчер виртуализирует delay`() = runTest {
|
||
val wall = System.currentTimeMillis()
|
||
val service = Injected(StandardTestDispatcher(testScheduler))
|
||
assertEquals("результат", service.work())
|
||
assertTrue(System.currentTimeMillis() - wall < 500)
|
||
}
|
||
|
||
@Test
|
||
fun `жёстко заданный диспетчер ждёт реальную секунду`() = runTest {
|
||
val wall = System.currentTimeMillis()
|
||
assertEquals("результат", Hardcoded().work())
|
||
assertTrue(System.currentTimeMillis() - wall >= 900)
|
||
}
|
||
}
|
||
```
|
||
Оба теста проходят, но второй занимает реальную секунду. В продакшн-коде диспетчер стоит передавать параметром с значением по умолчанию:
|
||
```kotlin
|
||
import kotlinx.coroutines.CoroutineDispatcher
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.withContext
|
||
|
||
class Service(
|
||
private val io: CoroutineDispatcher = Dispatchers.IO,
|
||
) {
|
||
suspend fun load(): String = withContext(io) {
|
||
delay(1000)
|
||
"данные"
|
||
}
|
||
}
|
||
```
|
||
Вызывающий код ничего не меняет, а тест подставляет `StandardTestDispatcher(testScheduler)`. Подробнее: [Диспетчеры и контекст](../Корутины/Диспетчеры%20и%20контекст.md)
|
||
|
||
## Dispatchers.Main
|
||
`Dispatchers.Main` на JVM без UI-фреймворка не существует, поэтому его подменяют:
|
||
```kotlin
|
||
import kotlinx.coroutines.CoroutineScope
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.flow.MutableStateFlow
|
||
import kotlinx.coroutines.flow.StateFlow
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||
import kotlinx.coroutines.test.advanceUntilIdle
|
||
import kotlinx.coroutines.test.resetMain
|
||
import kotlinx.coroutines.test.runCurrent
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlinx.coroutines.test.setMain
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class ViewModelLike(private val scope: CoroutineScope) {
|
||
private val _state = MutableStateFlow("начало")
|
||
val state: StateFlow<String> = _state
|
||
|
||
fun load() {
|
||
scope.launch {
|
||
_state.value = "загрузка"
|
||
delay(1000)
|
||
_state.value = "готово"
|
||
}
|
||
}
|
||
}
|
||
|
||
class MainDispatcherTest {
|
||
@Test
|
||
fun `setMain подменяет Main на тестовый диспетчер`() = runTest {
|
||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||
try {
|
||
val vm = ViewModelLike(CoroutineScope(Dispatchers.Main))
|
||
vm.load()
|
||
runCurrent()
|
||
assertEquals("загрузка", vm.state.value)
|
||
advanceUntilIdle()
|
||
assertEquals("готово", vm.state.value)
|
||
} finally {
|
||
Dispatchers.resetMain()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
`setMain` и `resetMain` - глобальное состояние, поэтому `try`/`finally` обязателен. Повторять его в каждом тесте не нужно - проще вынести в расширение JUnit:
|
||
```kotlin
|
||
import kotlinx.coroutines.CoroutineScope
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.flow.MutableStateFlow
|
||
import kotlinx.coroutines.flow.StateFlow
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.TestDispatcher
|
||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||
import kotlinx.coroutines.test.advanceUntilIdle
|
||
import kotlinx.coroutines.test.resetMain
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlinx.coroutines.test.setMain
|
||
import org.junit.jupiter.api.extension.AfterEachCallback
|
||
import org.junit.jupiter.api.extension.BeforeEachCallback
|
||
import org.junit.jupiter.api.extension.ExtendWith
|
||
import org.junit.jupiter.api.extension.ExtensionContext
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class MainDispatcherExtension(
|
||
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||
) : BeforeEachCallback, AfterEachCallback {
|
||
override fun beforeEach(context: ExtensionContext) = Dispatchers.setMain(dispatcher)
|
||
override fun afterEach(context: ExtensionContext) = Dispatchers.resetMain()
|
||
}
|
||
|
||
class ViewModel {
|
||
private val scope = CoroutineScope(Dispatchers.Main)
|
||
private val _state = MutableStateFlow("начало")
|
||
val state: StateFlow<String> = _state
|
||
|
||
fun load() {
|
||
scope.launch {
|
||
_state.value = "загрузка"
|
||
delay(1000)
|
||
_state.value = "готово"
|
||
}
|
||
}
|
||
}
|
||
|
||
@ExtendWith(MainDispatcherExtension::class)
|
||
class ViewModelTest {
|
||
@Test
|
||
fun `Main подменён для всех тестов класса`() = runTest {
|
||
val vm = ViewModel()
|
||
vm.load()
|
||
assertEquals("загрузка", vm.state.value)
|
||
advanceUntilIdle()
|
||
assertEquals("готово", vm.state.value)
|
||
}
|
||
}
|
||
```
|
||
Подробнее про расширения: [kotlin.test и JUnit 5](kotlin.test%20и%20JUnit%205.md)
|
||
|
||
## Тестирование Flow
|
||
Конечный поток проверяется сборкой в список:
|
||
```kotlin
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.flow.flow
|
||
import kotlinx.coroutines.flow.take
|
||
import kotlinx.coroutines.flow.toList
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class FlowCollectTest {
|
||
@Test
|
||
fun `toList собирает конечный поток`() = runTest {
|
||
val source = flow {
|
||
emit(1); delay(100)
|
||
emit(2); delay(100)
|
||
emit(3)
|
||
}
|
||
assertEquals(listOf(1, 2, 3), source.toList())
|
||
}
|
||
|
||
@Test
|
||
fun `take ограничивает бесконечный поток`() = runTest {
|
||
val infinite = flow {
|
||
var i = 0
|
||
while (true) {
|
||
emit(i++)
|
||
delay(10)
|
||
}
|
||
}
|
||
assertEquals(listOf(0, 1, 2), infinite.take(3).toList())
|
||
}
|
||
}
|
||
```
|
||
`toList()` на бесконечном потоке подвесит тест до таймаута - ограничение через `take` обязательно.
|
||
|
||
### StateFlow и конфлюирование
|
||
```kotlin
|
||
import kotlinx.coroutines.flow.MutableStateFlow
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||
import kotlinx.coroutines.test.runCurrent
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class StateFlowTest {
|
||
@Test
|
||
fun `Unconfined видит каждое значение`() = runTest {
|
||
val state = MutableStateFlow(0)
|
||
val seen = mutableListOf<Int>()
|
||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||
state.collect { seen += it }
|
||
}
|
||
state.value = 1
|
||
state.value = 2
|
||
runCurrent()
|
||
assertEquals(listOf(0, 1, 2), seen)
|
||
}
|
||
|
||
@Test
|
||
fun `Standard конфлюирует промежуточные значения`() = runTest {
|
||
val state = MutableStateFlow(0)
|
||
val seen = mutableListOf<Int>()
|
||
backgroundScope.launch { state.collect { seen += it } }
|
||
state.value = 1
|
||
state.value = 2
|
||
runCurrent()
|
||
// коллектор не успел запуститься и получил только последнее
|
||
assertEquals(listOf(2), seen)
|
||
}
|
||
}
|
||
```
|
||
Разница принципиальная: со `StandardTestDispatcher` коллектор стартует только при `runCurrent`, к тому моменту `value` уже равно `2`, а `StateFlow` не хранит историю. Для проверки последовательности значений нужен `UnconfinedTestDispatcher(testScheduler)` - и обязательно с передачей `testScheduler`. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md)
|
||
|
||
Если проверяется только итог, достаточно `state.value` без коллектора вообще.
|
||
|
||
## Turbine
|
||
Ручной сбор в список быстро становится громоздким: нужно управлять scope, продвигать время и помнить про конфлюирование. Turbine даёт императивный API над потоком:
|
||
```kotlin
|
||
import app.cash.turbine.test
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||
import kotlinx.coroutines.flow.MutableStateFlow
|
||
import kotlinx.coroutines.flow.flow
|
||
import kotlinx.coroutines.flow.flowOf
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class TurbineTest {
|
||
@Test
|
||
fun `awaitItem забирает элементы по одному`() = runTest {
|
||
flow { emit(1); delay(100); emit(2) }.test {
|
||
assertEquals(1, awaitItem())
|
||
assertEquals(2, awaitItem())
|
||
awaitComplete()
|
||
}
|
||
}
|
||
|
||
@Test
|
||
fun `StateFlow отдаёт начальное значение первым`() = runTest {
|
||
val state = MutableStateFlow(0)
|
||
state.test {
|
||
assertEquals(0, awaitItem())
|
||
state.value = 1
|
||
assertEquals(1, awaitItem())
|
||
cancelAndIgnoreRemainingEvents()
|
||
}
|
||
}
|
||
|
||
@Test
|
||
fun `awaitError ловит исключение потока`() = runTest {
|
||
flow<Int> { emit(1); throw IllegalStateException("сбой") }.test {
|
||
assertEquals(1, awaitItem())
|
||
assertEquals("сбой", awaitError().message)
|
||
}
|
||
}
|
||
|
||
@Test
|
||
fun `skipItems и expectNoEvents`() = runTest {
|
||
flowOf(1, 2, 3, 4).test {
|
||
skipItems(2)
|
||
assertEquals(3, awaitItem())
|
||
assertEquals(4, awaitItem())
|
||
awaitComplete()
|
||
}
|
||
MutableSharedFlow<Int>().test {
|
||
expectNoEvents()
|
||
cancelAndIgnoreRemainingEvents()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
| Функция | Что делает |
|
||
|---|---|
|
||
| `awaitItem()` | ждёт следующий элемент |
|
||
| `awaitComplete()` | ждёт нормального завершения |
|
||
| `awaitError()` | ждёт исключения и возвращает его |
|
||
| `skipItems(n)` | пропускает `n` элементов |
|
||
| `expectNoEvents()` | проверяет, что событий не было |
|
||
| `expectMostRecentItem()` | берёт последний, отбрасывая предыдущие |
|
||
| `cancelAndIgnoreRemainingEvents()` | завершает сбор, не требуя разбора остатка |
|
||
| `cancelAndConsumeRemainingEvents()` | то же, но возвращает остаток |
|
||
|
||
Ключевое свойство: **незабранные события проваливают тест**. Если поток выдал три элемента, а проверены два, Turbine бросит `TurbineAssertionError` при выходе из блока. Это ловит лишние эмиссии, которые ручной `toList()` просто не замечает.
|
||
|
||
Несколько потоков сразу - через `turbineScope`:
|
||
```kotlin
|
||
import app.cash.turbine.turbineScope
|
||
import kotlinx.coroutines.flow.flowOf
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
class TurbineScopeTest {
|
||
@Test
|
||
fun `два потока проверяются вперемешку`() = runTest {
|
||
turbineScope {
|
||
val numbers = flowOf(1, 2).testIn(backgroundScope)
|
||
val letters = flowOf("x").testIn(backgroundScope)
|
||
|
||
assertEquals(1, numbers.awaitItem())
|
||
assertEquals("x", letters.awaitItem())
|
||
assertEquals(2, numbers.awaitItem())
|
||
numbers.awaitComplete()
|
||
letters.awaitComplete()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## Исключения и отмена
|
||
```kotlin
|
||
import kotlinx.coroutines.coroutineScope
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.launch
|
||
import kotlinx.coroutines.test.TestScope
|
||
import kotlinx.coroutines.test.advanceUntilIdle
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
import kotlin.test.assertTrue
|
||
import kotlin.time.Duration.Companion.seconds
|
||
|
||
class ErrorsTest {
|
||
@Test
|
||
fun `исключение дочерней корутины доходит до теста`() = runTest {
|
||
val e = runCatching {
|
||
coroutineScope {
|
||
launch { throw IllegalStateException("из дочерней") }
|
||
}
|
||
}.exceptionOrNull()
|
||
assertEquals("из дочерней", e?.message)
|
||
}
|
||
|
||
@Test
|
||
fun `незавершённая корутина в основном scope даёт UncompletedCoroutinesError`() {
|
||
val e = runCatching {
|
||
runTest(timeout = 1.seconds) {
|
||
launch { while (true) { delay(100) } }
|
||
}
|
||
}.exceptionOrNull()
|
||
// UncompletedCoroutinesError внутренний, поэтому проверяем по имени
|
||
assertEquals("UncompletedCoroutinesError", e!!::class.simpleName)
|
||
}
|
||
|
||
@Test
|
||
fun `TestScope создаётся вручную вне runTest`() {
|
||
val scope = TestScope()
|
||
var done = false
|
||
scope.launch { delay(1000); done = true }
|
||
scope.advanceUntilIdle()
|
||
assertTrue(done)
|
||
}
|
||
}
|
||
```
|
||
Необработанное исключение дочерней корутины проваливает `runTest` - специально ловить его не нужно. `TestScope()` вручную пригодится, когда тестируемый класс сам принимает `CoroutineScope`: в этом случае scope передаётся в конструктор, а тест управляет временем через `advanceUntilIdle`. Подробнее: [Исключения в корутинах](../Корутины/Исключения%20в%20корутинах.md)
|
||
|
||
## Моки suspend-функций
|
||
```kotlin
|
||
import io.mockk.coEvery
|
||
import io.mockk.coVerify
|
||
import io.mockk.mockk
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.test.currentTime
|
||
import kotlinx.coroutines.test.runTest
|
||
import kotlin.test.Test
|
||
import kotlin.test.assertEquals
|
||
|
||
interface Api {
|
||
suspend fun fetch(id: Int): String
|
||
}
|
||
|
||
class Repository(private val api: Api) {
|
||
suspend fun load(id: Int): String = "данные: ${api.fetch(id)}"
|
||
}
|
||
|
||
class MockSuspendTest {
|
||
@Test
|
||
fun `coEvery с виртуальной задержкой`() = runTest {
|
||
val api = mockk<Api>()
|
||
coEvery { api.fetch(1) } coAnswers {
|
||
delay(5000)
|
||
"ответ"
|
||
}
|
||
|
||
assertEquals("данные: ответ", Repository(api).load(1))
|
||
assertEquals(5000, currentTime)
|
||
coVerify(exactly = 1) { api.fetch(1) }
|
||
}
|
||
}
|
||
```
|
||
`delay` внутри `coAnswers` под `runTest` виртуализируется - имитация медленного сетевого вызова не замедляет тест. Подробнее: [MockK](MockK.md)
|
||
|
||
## Корутины в Kotest
|
||
```kotlin
|
||
import io.kotest.core.spec.style.StringSpec
|
||
import io.kotest.matchers.shouldBe
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.withContext
|
||
import kotlinx.coroutines.test.currentTime
|
||
import kotlinx.coroutines.test.runTest
|
||
|
||
class KotestCoroutines : StringSpec({
|
||
"тело теста уже suspend" {
|
||
withContext(Dispatchers.Default) { 42 } shouldBe 42
|
||
}
|
||
"runTest нужен для виртуального времени" {
|
||
runTest {
|
||
delay(10_000)
|
||
currentTime shouldBe 10_000
|
||
}
|
||
}
|
||
})
|
||
```
|
||
Тело теста в Kotest - уже `suspend`-функция, поэтому `suspend`-вызовы работают без обёртки. Но виртуального времени там нет: `delay(100)` займёт реальные 100 мс, пока тест не завёрнут в `runTest`. Подробнее: [Kotest](Kotest.md)
|
||
|
||
> [!info] Отличия от Java
|
||
> | Java | Kotlin |
|
||
> |---|---|
|
||
> | `Thread.sleep` в тесте - реальная пауза | `delay` в `runTest` виртуален |
|
||
> | `CountDownLatch` для синхронизации | `advanceUntilIdle()` |
|
||
> | `Awaitility.await().until { }` | детерминированный планировщик |
|
||
> | `future.get(5, SECONDS)` | `runTest { }` ждёт сам |
|
||
> | `ExecutorService` + `shutdownNow` | `backgroundScope` отменяется сам |
|
||
> | флаки из-за таймингов | порядок задан планировщиком |
|
||
>
|
||
> **Виртуальное время заменяет ожидание реального.** В Java тест асинхронного кода либо спит (`Thread.sleep(100)` - медленно и ненадёжно), либо синхронизируется через `CountDownLatch` и `Awaitility` - это работает, но требует явных точек синхронизации в продакшн-коде или угадывания таймаутов. `TestCoroutineScheduler` вместо этого контролирует время: `delay` регистрирует задачу на виртуальный момент, а `advanceTimeBy` исполняет её немедленно. Тест становится детерминированным и мгновенным одновременно. Подробнее: [Взаимодейтсвие между потоками в Java](../../Программирование%20на%20Java/Многопоточность/Взаимодейтсвие%20между%20потоками.md)
|
||
>
|
||
> **Порядок выполнения задан, а не случаен.** Java-тесты на `ExecutorService` зависят от решений планировщика ОС: один и тот же тест проходит локально и падает в CI. `StandardTestDispatcher` исполняет корутины в строго определённом порядке - по очереди задач. Это убирает флаки, но меняет требования к коду: тест, рассчитывающий на немедленный запуск `launch`, нужно писать с `runCurrent()`.
|
||
>
|
||
> **Структурированная конкурентность делает «забытую задачу» ошибкой компилятора времени выполнения.** В Java поток, оставшийся после теста, просто живёт дальше и может испортить следующий тест или задержать завершение JVM. `runTest` ждёт все дочерние корутины своего scope и бросает `UncompletedCoroutinesError`, если какая-то не завершилась. Для осознанно долгоживущих задач предусмотрен `backgroundScope` - забыть отменить подписку в тесте просто нельзя. Подробнее: [Структурированная конкурентность](../Корутины/Структурированная%20конкурентность.md)
|
||
>
|
||
> **`Dispatchers.Main` приходится подменять, потому что это не настоящий диспетчер.** В Java нет понятия «главного потока» на уровне стандартной библиотеки - есть Swing EDT и Android main looper, каждый со своим API. Kotlin вводит `Dispatchers.Main` как общую абстракцию, и на JVM без UI-фреймворка он отсутствует: обращение без `setMain` даёт `IllegalStateException`. Это цена единого API для всех платформ.
|
||
>
|
||
> Сравнить: [Reflection API в Java](../../Программирование%20на%20Java/Рефлексия/Reflection%20API.md)
|
||
|
||
> [!warning] Подводные камни
|
||
> **Диспетчер без `testScheduler` подвешивает тест.** `UnconfinedTestDispatcher()` вне `runTest` создаёт **свой** планировщик, поэтому `advanceUntilIdle()` на него не действует: корутина остаётся активной, и тест падает с `UncompletedCoroutinesError` через 60 секунд. Всегда передавайте планировщик: `UnconfinedTestDispatcher(testScheduler)`.
|
||
>
|
||
> **`Dispatchers.Main` без `setMain` роняет не только тест, а весь прогон.** Исключение возникает внутри потока корутины, поэтому может не попасть в отчёт конкретного теста и проявиться как необъяснимое падение JVM. Подменяйте `Main` в `@BeforeEach` или через расширение.
|
||
>
|
||
> **`Dispatchers.setMain` - глобальное состояние.** Забытый `resetMain` ломает тесты, которым нужен настоящий `Main`, причём порядок тестов не гарантирован. Используйте `try`/`finally` или расширение JUnit.
|
||
>
|
||
> **`StateFlow` конфлюирует, и промежуточные значения теряются.** Со `StandardTestDispatcher` коллектор запускается позже эмиссий и увидит только последнее значение - проверено: из `0 → 1 → 2` в список попадёт `[2]`. Для последовательности нужен `UnconfinedTestDispatcher(testScheduler)`. Подробнее: [StateFlow и SharedFlow](../Корутины/StateFlow%20и%20SharedFlow.md)
|
||
>
|
||
> **`MutableSharedFlow` без буфера приостанавливает `emit` до появления подписчика.** Это не потеря данных, как часто пишут, а приостановка: `launch { shared.emit(1) }` до подписки дождётся коллектора и доставит значение. Но если `emit` вызван напрямую в теле теста, тест повиснет. Подписывайтесь до эмиссии и вызывайте `runCurrent()`.
|
||
>
|
||
> **Жёстко прописанный `Dispatchers.IO` или `Default` делает тест реально медленным.** `withContext(Dispatchers.IO) { delay(1000) }` займёт настоящую секунду даже внутри `runTest` - планировщик теста такой диспетчер не контролирует. Внедряйте диспетчер параметром.
|
||
>
|
||
> **Бесконечная корутина в основном scope `runTest` даёт `UncompletedCoroutinesError`.** `launch { while (true) { delay(100) } }` ждёт до таймаута (60 с по умолчанию). Для долгоживущих задач используйте `backgroundScope`.
|
||
>
|
||
> **`toList()` на бесконечном потоке висит до таймаута.** Ограничивайте через `take(n)`, `timeout` или проверяйте через Turbine с `cancelAndIgnoreRemainingEvents()`.
|
||
>
|
||
> **Незабранные события в Turbine проваливают тест - и это правильно.** `TurbineAssertionError` при выходе из `test { }` означает, что поток выдал больше элементов, чем проверено. Если остаток не важен, завершайте явным `cancelAndIgnoreRemainingEvents()`.
|
||
>
|
||
> **`advanceUntilIdle()` не исполняет задачи чужих планировщиков.** Если тестируемый класс создаёт корутины на `Dispatchers.Default`, они продолжат работать в реальном времени параллельно тесту - результат непредсказуем.
|
||
>
|
||
> **`runTest` ждёт дочерние корутины, но не сами задачи `Dispatchers.Default`.** Тест может завершиться раньше реальной фоновой работы, а исключение из неё всплывёт в следующем тесте.
|
||
>
|
||
> **`currentTime` требует отдельного импорта.** Это свойство-расширение `TestScope`, а не член класса, поэтому `import kotlinx.coroutines.test.currentTime` обязателен - без него IDE покажет `unresolved reference` внутри `runTest`. В обычной `suspend`-функции вне `TestScope` его нет вовсе.
|
||
>
|
||
> **`runTest(timeout = ...)` - реальный таймаут, а не виртуальный.** Он ограничивает физическое время прогона и не связан с `currentTime`; виртуально тест может «прожить» часы.
|
||
>
|
||
> **Версии `kotlinx-coroutines-core` и `-test` должны совпадать.** Расхождение даёт `NoSuchMethodError` в рантайме, причём сообщение не указывает на причину. Держите версию в `libs.versions.toml`. Подробнее: [Gradle и Kotlin DSL](../Инструментарий/Gradle%20и%20Kotlin%20DSL.md)
|
||
>
|
||
> **В Kotest тело теста `suspend`, но времени это не виртуализирует.** `delay(100)` в спецификации занимает реальные 100 мс. Для виртуального времени оборачивайте в `runTest` явно.
|
||
|
||
Далее: [Рефлексия](../Рефлексия/Рефлексия.md)
|