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

35 KiB
Raw Blame History

title, description, tags
title description tags
Тестирование корутин runTest, виртуальное время, TestDispatcher, backgroundScope, тестирование Flow и Turbine
kotlin
testing
coroutines

Корутины ломают привычную модель теста: результат появляется не в момент вызова, а когда-то позже, а delay делает тест медленным. Библиотека kotlinx-coroutines-test решает обе проблемы - она даёт runTest с виртуальным временем, где delay(10.seconds) выполняется мгновенно.

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)
    }
}

Тест завершается за миллисекунды, хотя по виртуальным часам прошло десять секунд.

Подключение

// 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

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 в конце не требуется.

Диспетчеры теста

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

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 в продакшн-коде делает тест реально медленным:

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)
    }
}

Оба теста проходят, но второй занимает реальную секунду. В продакшн-коде диспетчер стоит передавать параметром с значением по умолчанию:

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). Подробнее: Диспетчеры и контекст

Dispatchers.Main

Dispatchers.Main на JVM без UI-фреймворка не существует, поэтому его подменяют:

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:

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

Тестирование Flow

Конечный поток проверяется сборкой в список:

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 и конфлюирование

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

Если проверяется только итог, достаточно state.value без коллектора вообще.

Turbine

Ручной сбор в список быстро становится громоздким: нужно управлять scope, продвигать время и помнить про конфлюирование. Turbine даёт императивный API над потоком:

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:

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()
        }
    }
}

Исключения и отмена

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. Подробнее: Исключения в корутинах

Моки suspend-функций

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

Корутины в Kotest

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

[!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

Порядок выполнения задан, а не случаен. Java-тесты на ExecutorService зависят от решений планировщика ОС: один и тот же тест проходит локально и падает в CI. StandardTestDispatcher исполняет корутины в строго определённом порядке - по очереди задач. Это убирает флаки, но меняет требования к коду: тест, рассчитывающий на немедленный запуск launch, нужно писать с runCurrent().

Структурированная конкурентность делает «забытую задачу» ошибкой компилятора времени выполнения. В Java поток, оставшийся после теста, просто живёт дальше и может испортить следующий тест или задержать завершение JVM. runTest ждёт все дочерние корутины своего scope и бросает UncompletedCoroutinesError, если какая-то не завершилась. Для осознанно долгоживущих задач предусмотрен backgroundScope - забыть отменить подписку в тесте просто нельзя. Подробнее: Структурированная конкурентность

Dispatchers.Main приходится подменять, потому что это не настоящий диспетчер. В Java нет понятия «главного потока» на уровне стандартной библиотеки - есть Swing EDT и Android main looper, каждый со своим API. Kotlin вводит Dispatchers.Main как общую абстракцию, и на JVM без UI-фреймворка он отсутствует: обращение без setMain даёт IllegalStateException. Это цена единого API для всех платформ.

Сравнить: Reflection API в Java

[!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

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

В Kotest тело теста suspend, но времени это не виртуализирует. delay(100) в спецификации занимает реальные 100 мс. Для виртуального времени оборачивайте в runTest явно.

Далее: Рефлексия