35 KiB
title, description, tags
| title | description | tags | |||
|---|---|---|---|---|---|
| Тестирование корутин | runTest, виртуальное время, TestDispatcher, backgroundScope, тестирование Flow и Turbine |
|
Корутины ломают привычную модель теста: результат появляется не в момент вызова, а когда-то позже, а 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+shutdownNowbackgroundScopeотменяется самфлаки из-за таймингов порядок задан планировщиком Виртуальное время заменяет ожидание реального. В 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явно.
Далее: Рефлексия