Files
obsidian/Kotlin/Тестирование/Тестирование корутин.md
T

681 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)